diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index bbee13fc..c309da34 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -15,6 +15,7 @@ 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 { extensionsCommand } from '../ui/commands/extensionsCommand.js'; // Mock the command modules to isolate the service from the command implementations. vi.mock('../ui/commands/memoryCommand.js', () => ({ @@ -41,6 +42,9 @@ vi.mock('../ui/commands/statsCommand.js', () => ({ vi.mock('../ui/commands/aboutCommand.js', () => ({ aboutCommand: { name: 'about', description: 'Mock About' }, })); +vi.mock('../ui/commands/extensionsCommand.js', () => ({ + extensionsCommand: { name: 'extensions', description: 'Mock Extensions' }, +})); describe('CommandService', () => { describe('when using default production loader', () => { @@ -66,7 +70,7 @@ describe('CommandService', () => { const tree = commandService.getCommands(); // Post-condition assertions - expect(tree.length).toBe(8); + expect(tree.length).toBe(9); const commandNames = tree.map((cmd) => cmd.name); expect(commandNames).toContain('auth'); @@ -77,19 +81,20 @@ describe('CommandService', () => { expect(commandNames).toContain('stats'); expect(commandNames).toContain('privacy'); expect(commandNames).toContain('about'); + expect(commandNames).toContain('extensions'); }); it('should overwrite any existing commands when called again', async () => { // Load once await commandService.loadCommands(); - expect(commandService.getCommands().length).toBe(8); + expect(commandService.getCommands().length).toBe(9); // Load again await commandService.loadCommands(); const tree = commandService.getCommands(); // Should not append, but overwrite - expect(tree.length).toBe(8); + expect(tree.length).toBe(9); }); }); @@ -101,11 +106,12 @@ describe('CommandService', () => { await commandService.loadCommands(); const loadedTree = commandService.getCommands(); - expect(loadedTree.length).toBe(8); + expect(loadedTree.length).toBe(9); expect(loadedTree).toEqual([ aboutCommand, authCommand, clearCommand, + extensionsCommand, helpCommand, memoryCommand, privacyCommand, diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index cc7b4e62..379d0638 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -13,11 +13,13 @@ 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 { extensionsCommand } from '../ui/commands/extensionsCommand.js'; const loadBuiltInCommands = async (): Promise => [ aboutCommand, authCommand, clearCommand, + extensionsCommand, helpCommand, memoryCommand, privacyCommand, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts new file mode 100644 index 00000000..a989d9b0 --- /dev/null +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { extensionsCommand } from './extensionsCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; + +describe('extensionsCommand', () => { + let mockContext: CommandContext; + + it('should display "No active extensions." when none are found', async () => { + mockContext = createMockCommandContext({ + services: { + config: { + getActiveExtensions: () => [], + }, + }, + }); + + if (!extensionsCommand.action) throw new Error('Action not defined'); + await extensionsCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'No active extensions.', + }, + expect.any(Number), + ); + }); + + it('should list active extensions when they are found', async () => { + const mockExtensions = [ + { name: 'ext-one', version: '1.0.0' }, + { name: 'ext-two', version: '2.1.0' }, + ]; + mockContext = createMockCommandContext({ + services: { + config: { + getActiveExtensions: () => mockExtensions, + }, + }, + }); + + if (!extensionsCommand.action) throw new Error('Action not defined'); + await extensionsCommand.action(mockContext, ''); + + const expectedMessage = + 'Active extensions:\n\n' + + ` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` + + ` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`; + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: expectedMessage, + }, + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts new file mode 100644 index 00000000..87d23afb --- /dev/null +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandContext, type SlashCommand } from './types.js'; +import { MessageType } from '../types.js'; + +export const extensionsCommand: SlashCommand = { + name: 'extensions', + description: 'list active extensions', + action: async (context: CommandContext): Promise => { + const activeExtensions = context.services.config?.getActiveExtensions(); + if (!activeExtensions || activeExtensions.length === 0) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'No active extensions.', + }, + Date.now(), + ); + return; + } + + const extensionLines = activeExtensions.map( + (ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`, + ); + const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`; + + context.ui.addItem( + { + type: MessageType.INFO, + text: message, + }, + Date.now(), + ); + }, +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 31397af5..139de06e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -446,34 +446,6 @@ export const useSlashCommandProcessor = ( }); }, }, - { - name: 'extensions', - description: 'list active extensions', - action: async () => { - const activeExtensions = config?.getActiveExtensions(); - if (!activeExtensions || activeExtensions.length === 0) { - addMessage({ - type: MessageType.INFO, - content: 'No active extensions.', - timestamp: new Date(), - }); - return; - } - - let message = 'Active extensions:\n\n'; - for (const ext of activeExtensions) { - message += ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m\n`; - } - // Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal - message += '\u001b[0m'; - - addMessage({ - type: MessageType.INFO, - content: message, - timestamp: new Date(), - }); - }, - }, { name: 'tools', description: 'list available Gemini CLI tools',