From 7b07567c08b6ff46ba45520c854d413c9c3de09d Mon Sep 17 00:00:00 2001 From: mingmen Date: Mon, 15 Sep 2025 16:35:43 +0800 Subject: [PATCH] feat(CommandService): add command filtering configuration options This commit adds a new configuration option `Settings` to the `CommandService`, allowing command filtering based on core commands and excluded commands settings. Relevant tests and configuration patterns have also been updated. --- packages/cli/src/config/settingsSchema.ts | 18 ++ .../cli/src/services/CommandService.test.ts | 237 +++++++++++++++++- packages/cli/src/services/CommandService.ts | 23 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 3 +- 4 files changed, 278 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 664820bdc..f619bcebd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -595,6 +595,24 @@ export const SETTINGS_SCHEMA = { description: 'The API key for the Tavily API.', showInDialog: false, }, + coreCommands: { + type: 'array', + label: 'Core Commands', + category: 'Commands', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A list of core command names to load. If specified, only these commands will be loaded.', + showInDialog: false, + }, + excludeCommands: { + type: 'array', + label: 'Exclude Commands', + category: 'Commands', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A list of command names to exclude from loading.', + showInDialog: false, + }, skipNextSpeakerCheck: { type: 'boolean', label: 'Skip Next Speaker Check', diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index e2d5b9f58..348fce218 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -47,6 +47,7 @@ describe('CommandService', () => { const service = await CommandService.create( [mockLoader], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -64,6 +65,7 @@ describe('CommandService', () => { const service = await CommandService.create( [loader1, loader2], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -85,6 +87,7 @@ describe('CommandService', () => { const service = await CommandService.create( [loader1, loader2], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -114,6 +117,7 @@ describe('CommandService', () => { const service = await CommandService.create( [loader1, emptyLoader, loader3], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -134,6 +138,7 @@ describe('CommandService', () => { const service = await CommandService.create( [successfulLoader, failingLoader], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -149,6 +154,7 @@ describe('CommandService', () => { const service = await CommandService.create( [new MockCommandLoader([mockCommandA])], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -170,7 +176,7 @@ describe('CommandService', () => { const loader1 = new MockCommandLoader([mockCommandA]); const loader2 = new MockCommandLoader([mockCommandB]); - await CommandService.create([loader1, loader2], signal); + await CommandService.create([loader1, loader2], signal, undefined); expect(loader1.loadCommands).toHaveBeenCalledTimes(1); expect(loader1.loadCommands).toHaveBeenCalledWith(signal); @@ -202,6 +208,7 @@ describe('CommandService', () => { const service = await CommandService.create( [mockLoader1, mockLoader2], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -252,6 +259,7 @@ describe('CommandService', () => { const service = await CommandService.create( [mockLoader1, mockLoader2], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -289,6 +297,7 @@ describe('CommandService', () => { const service = await CommandService.create( [mockLoader], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -337,6 +346,7 @@ describe('CommandService', () => { const service = await CommandService.create( [mockLoader], new AbortController().signal, + undefined, ); const commands = service.getCommands(); @@ -349,4 +359,229 @@ describe('CommandService', () => { expect(deployExtension).toBeDefined(); expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); + + it('should filter commands based on coreCommands setting', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + const commandC = createMockCommand('command-c', CommandKind.FILE); + + const mockLoader = new MockCommandLoader([commandA, commandB, commandC]); + + const settings = { + coreCommands: ['command-a', 'command-c'], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(2); + expect(commands).toEqual( + expect.arrayContaining([commandA, commandC]), + ); + expect(commands).not.toContain(commandB); + }); + + it('should filter commands based on excludeCommands setting', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + const commandC = createMockCommand('command-c', CommandKind.FILE); + + const mockLoader = new MockCommandLoader([commandA, commandB, commandC]); + + const settings = { + excludeCommands: ['command-b'], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(2); + expect(commands).toEqual( + expect.arrayContaining([commandA, commandC]), + ); + expect(commands).not.toContain(commandB); + }); + + it('should exclude commands that appear in both coreCommands and excludeCommands', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + const commandC = createMockCommand('command-c', CommandKind.FILE); + + const mockLoader = new MockCommandLoader([commandA, commandB, commandC]); + + const settings = { + coreCommands: ['command-a', 'command-b'], + excludeCommands: ['command-b', 'command-c'], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(1); + expect(commands).toEqual( + expect.arrayContaining([commandA]), + ); + expect(commands).not.toContain(commandB); + expect(commands).not.toContain(commandC); + }); + + it('should handle empty coreCommands array', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + + const mockLoader = new MockCommandLoader([commandA, commandB]); + + const settings = { + coreCommands: [], + excludeCommands: ['command-b'], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(1); + expect(commands).toEqual( + expect.arrayContaining([commandA]), + ); + expect(commands).not.toContain(commandB); + }); + + it('should handle empty excludeCommands array', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + + const mockLoader = new MockCommandLoader([commandA, commandB]); + + const settings = { + coreCommands: ['command-a'], + excludeCommands: [], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(1); + expect(commands).toEqual( + expect.arrayContaining([commandA]), + ); + expect(commands).not.toContain(commandB); + }); + + it('should handle both arrays being empty', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + + const mockLoader = new MockCommandLoader([commandA, commandB]); + + const settings = { + coreCommands: [], + excludeCommands: [], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(2); + expect(commands).toEqual( + expect.arrayContaining([commandA, commandB]), + ); + }); + + it('should handle empty coreCommands array', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + + const mockLoader = new MockCommandLoader([commandA, commandB]); + + const settings = { + coreCommands: [], + excludeCommands: ['command-b'], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(1); + expect(commands).toEqual( + expect.arrayContaining([commandA]), + ); + expect(commands).not.toContain(commandB); + }); + + it('should handle empty excludeCommands array', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + + const mockLoader = new MockCommandLoader([commandA, commandB]); + + const settings = { + coreCommands: ['command-a'], + excludeCommands: [], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(1); + expect(commands).toEqual( + expect.arrayContaining([commandA]), + ); + expect(commands).not.toContain(commandB); + }); + + it('should handle both arrays being empty', async () => { + const commandA = createMockCommand('command-a', CommandKind.BUILT_IN); + const commandB = createMockCommand('command-b', CommandKind.BUILT_IN); + + const mockLoader = new MockCommandLoader([commandA, commandB]); + + const settings = { + coreCommands: [], + excludeCommands: [], + }; + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + settings, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(2); + expect(commands).toEqual( + expect.arrayContaining([commandA, commandB]), + ); + }); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 78e4817b0..88ca87168 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -6,6 +6,7 @@ import { SlashCommand } from '../ui/commands/types.js'; import { ICommandLoader } from './types.js'; +import { Settings } from '../config/settings.js'; /** * Orchestrates the discovery and loading of all slash commands for the CLI. @@ -42,11 +43,13 @@ export class CommandService { * @param loaders An array of objects that conform to the `ICommandLoader` * interface. Built-in commands should come first, followed by FileCommandLoader. * @param signal An AbortSignal to cancel the loading process. + * @param settings Optional settings to control which commands to load. * @returns A promise that resolves to a new, fully initialized `CommandService` instance. */ static async create( loaders: ICommandLoader[], signal: AbortSignal, + settings?: Settings, ): Promise { const results = await Promise.allSettled( loaders.map((loader) => loader.loadCommands(signal)), @@ -61,8 +64,26 @@ export class CommandService { } } + // Filter commands based on coreCommands and excludeCommands settings + let filteredCommands = allCommands; + + // Create sets for efficient lookup + const coreCommandSet = new Set(settings?.coreCommands || []); + const excludeCommandSet = new Set(settings?.excludeCommands || []); + + // If coreCommands is specified, only include those commands + if (coreCommandSet.size > 0) { + filteredCommands = filteredCommands.filter(cmd => + coreCommandSet.has(cmd.name) && !excludeCommandSet.has(cmd.name) + ); + } else if (excludeCommandSet.size > 0) { + filteredCommands = filteredCommands.filter(cmd => + !excludeCommandSet.has(cmd.name) + ); + } + const commandMap = new Map(); - for (const cmd of allCommands) { + for (const cmd of filteredCommands) { let finalName = cmd.name; // Extension commands get renamed if they conflict with existing commands diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 2462b02e7..7e913991f 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -252,6 +252,7 @@ export const useSlashCommandProcessor = ( const commandService = await CommandService.create( loaders, controller.signal, + settings.merged, ); setCommands(commandService.getCommands()); }; @@ -261,7 +262,7 @@ export const useSlashCommandProcessor = ( return () => { controller.abort(); }; - }, [config, reloadTrigger]); + }, [config, settings, reloadTrigger]); const handleSlashCommand = useCallback( async (