diff --git a/packages/a2a-server/src/commands/command-registry.test.ts b/packages/a2a-server/src/commands/command-registry.test.ts new file mode 100644 index 0000000000..2bcd0c4428 --- /dev/null +++ b/packages/a2a-server/src/commands/command-registry.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('CommandRegistry', () => { + const mockListExtensionsCommandInstance = { + names: ['extensions', 'extensions list'], + execute: vi.fn(), + }; + const mockListExtensionsCommand = vi.fn( + () => mockListExtensionsCommandInstance, + ); + + beforeEach(async () => { + vi.resetModules(); + vi.doMock('./list-extensions', () => ({ + ListExtensionsCommand: mockListExtensionsCommand, + })); + }); + + it('should register ListExtensionsCommand on initialization', async () => { + const { commandRegistry } = await import('./command-registry.js'); + expect(mockListExtensionsCommand).toHaveBeenCalled(); + const command = commandRegistry.get('extensions'); + expect(command).toBe(mockListExtensionsCommandInstance); + }); + + it('get() should return undefined for a non-existent command', async () => { + const { commandRegistry } = await import('./command-registry.js'); + const command = commandRegistry.get('non-existent'); + expect(command).toBeUndefined(); + }); + + it('register() should register a new command', async () => { + const { commandRegistry } = await import('./command-registry.js'); + const mockCommand = { + names: ['test-command'], + execute: vi.fn(), + }; + commandRegistry.register(mockCommand); + const command = commandRegistry.get('test-command'); + expect(command).toBe(mockCommand); + }); +}); diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts new file mode 100644 index 0000000000..3d82bfd45d --- /dev/null +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ListExtensionsCommand } from './list-extensions.js'; +import type { Config } from '@google/gemini-cli-core'; + +export interface Command { + readonly names: string[]; + execute(config: Config, args: string[]): Promise; +} + +class CommandRegistry { + private readonly commands = new Map(); + + constructor() { + this.register(new ListExtensionsCommand()); + } + + register(command: Command) { + for (const name of command.names) { + this.commands.set(name, command); + } + } + + get(commandName: string): Command | undefined { + return this.commands.get(commandName); + } +} + +export const commandRegistry = new CommandRegistry(); diff --git a/packages/a2a-server/src/commands/list-extensions.test.ts b/packages/a2a-server/src/commands/list-extensions.test.ts new file mode 100644 index 0000000000..42c3560f92 --- /dev/null +++ b/packages/a2a-server/src/commands/list-extensions.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ListExtensionsCommand } from './list-extensions.js'; +import type { Config } from '@google/gemini-cli-core'; + +const mockListExtensions = vi.hoisted(() => vi.fn()); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + listExtensions: mockListExtensions, + }; +}); + +describe('ListExtensionsCommand', () => { + it('should have the correct names', () => { + const command = new ListExtensionsCommand(); + expect(command.names).toEqual(['extensions', 'extensions list']); + }); + + it('should call listExtensions with the provided config', async () => { + const command = new ListExtensionsCommand(); + const mockConfig = {} as Config; + const mockExtensions = [{ name: 'ext1' }]; + mockListExtensions.mockReturnValue(mockExtensions); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual(mockExtensions); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig); + }); +}); diff --git a/packages/a2a-server/src/commands/list-extensions.ts b/packages/a2a-server/src/commands/list-extensions.ts new file mode 100644 index 0000000000..fa2fe5d84e --- /dev/null +++ b/packages/a2a-server/src/commands/list-extensions.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { listExtensions, type Config } from '@google/gemini-cli-core'; +import type { Command } from './command-registry.js'; + +export class ListExtensionsCommand implements Command { + readonly names = ['extensions', 'extensions list']; + + async execute(config: Config, _: string[]): Promise { + return listExtensions(config); + } +} diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 1205c20351..c75c902ca5 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -71,6 +71,7 @@ export async function loadConfig( }, ideMode: false, folderTrust: settings.folderTrust === true, + extensions, }; const fileService = new FileDiscoveryService(workspaceDir); diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 787fc86050..70d90f78cb 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -65,6 +65,8 @@ let config: Config; const getToolRegistrySpy = vi.fn().mockReturnValue(ApprovalMode.DEFAULT); const getApprovalModeSpy = vi.fn(); const getShellExecutionConfigSpy = vi.fn(); +const getExtensionsSpy = vi.fn(); + vi.mock('../config/config.js', async () => { const actual = await vi.importActual('../config/config.js'); return { @@ -74,6 +76,7 @@ vi.mock('../config/config.js', async () => { getToolRegistry: getToolRegistrySpy, getApprovalMode: getApprovalModeSpy, getShellExecutionConfig: getShellExecutionConfigSpy, + getExtensions: getExtensionsSpy, }); config = mockConfig as Config; return config; @@ -652,4 +655,62 @@ describe('E2E Tests', () => { expect(thoughtEvent.kind).toBe('status-update'); expect(thoughtEvent.metadata?.['traceId']).toBe(traceId); }); + + describe('/executeCommand', () => { + const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }]; + + beforeEach(() => { + getExtensionsSpy.mockReturnValue(mockExtensions); + }); + + afterEach(() => { + getExtensionsSpy.mockClear(); + }); + + it('should return extensions for valid command', async () => { + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'extensions list', args: [] }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(res.body).toEqual(mockExtensions); + expect(getExtensionsSpy).toHaveBeenCalled(); + }); + + it('should return 404 for invalid command', async () => { + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'invalid command' }) + .set('Content-Type', 'application/json') + .expect(404); + + expect(res.body.error).toBe('Command not found: invalid command'); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + + it('should return 400 for missing command', async () => { + const agent = request.agent(app); + await agent + .post('/executeCommand') + .send({ args: [] }) + .set('Content-Type', 'application/json') + .expect(400); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + + it('should return 400 if args is not an array', async () => { + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'extensions.list', args: 'not-an-array' }) + .set('Content-Type', 'application/json') + .expect(400); + + expect(res.body.error).toBe('"args" field must be an array.'); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index dca5fee058..e7b45d347c 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -16,6 +16,10 @@ import type { AgentSettings } from '../types.js'; import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js'; import { CoderAgentExecutor } from '../agent/executor.js'; import { requestStorage } from './requestStorage.js'; +import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; +import { loadSettings } from '../config/settings.js'; +import { loadExtensions } from '../config/extension.js'; +import { commandRegistry } from '../commands/command-registry.js'; const coderAgentCard: AgentCard = { name: 'Gemini SDLC Agent', @@ -61,6 +65,13 @@ export function updateCoderAgentCardUrl(port: number) { export async function createApp() { try { + // Load the server configuration once on startup. + const workspaceRoot = setTargetDir(undefined); + loadEnvironment(); + const settings = loadSettings(workspaceRoot); + const extensions = loadExtensions(workspaceRoot); + const config = await loadConfig(settings, extensions, 'a2a-server'); + // loadEnvironment() is called within getConfig now const bucketName = process.env['GCS_BUCKET_NAME']; let taskStoreForExecutor: TaskStore; @@ -119,6 +130,38 @@ export async function createApp() { } }); + expressApp.post('/executeCommand', async (req, res) => { + try { + const { command, args } = req.body; + + if (typeof command !== 'string') { + return res.status(400).json({ error: 'Invalid "command" field.' }); + } + + if (args && !Array.isArray(args)) { + return res + .status(400) + .json({ error: '"args" field must be an array.' }); + } + + const commandToExecute = commandRegistry.get(command); + + if (!commandToExecute) { + return res + .status(404) + .json({ error: `Command not found: ${command}` }); + } + + const result = await commandToExecute.execute(config, args ?? []); + return res.status(200).json(result); + } catch (e) { + logger.error('Error executing /executeCommand:', e); + const errorMessage = + e instanceof Error ? e.message : 'Unknown error executing command'; + return res.status(500).json({ error: errorMessage }); + } + }); + expressApp.get('/tasks/metadata', async (req, res) => { // This endpoint is only meaningful if the task store is in-memory. if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) { diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 130a452e1a..562744a0de 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -25,6 +25,7 @@ describe('extensionsCommand', () => { beforeEach(() => { vi.resetAllMocks(); + mockGetExtensions.mockReturnValue([]); mockContext = createMockCommandContext({ services: { config: { @@ -46,6 +47,7 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); @@ -113,11 +115,13 @@ describe('extensionsCommand', () => { await updateAction(mockContext, '--all'); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); @@ -130,11 +134,13 @@ describe('extensionsCommand', () => { await updateAction(mockContext, '--all'); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); @@ -202,11 +208,13 @@ describe('extensionsCommand', () => { }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 87f126b161..612de23cc6 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { listExtensions } from '@google/gemini-cli-core'; import type { ExtensionUpdateInfo } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemExtensionsList } from '../types.js'; import { type CommandContext, type SlashCommand, @@ -14,12 +15,14 @@ import { } from './types.js'; async function listAction(context: CommandContext) { - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); + const historyItem: HistoryItemExtensionsList = { + type: MessageType.EXTENSIONS_LIST, + extensions: context.services.config + ? listExtensions(context.services.config) + : [], + }; + + context.ui.addItem(historyItem, Date.now()); } function updateAction(context: CommandContext, args: string): Promise { @@ -42,6 +45,14 @@ function updateAction(context: CommandContext, args: string): Promise { const updateComplete = new Promise( (resolve) => (resolveUpdateComplete = resolve), ); + + const historyItem: HistoryItemExtensionsList = { + type: MessageType.EXTENSIONS_LIST, + extensions: context.services.config + ? listExtensions(context.services.config) + : [], + }; + updateComplete.then((updateInfos) => { if (updateInfos.length === 0) { context.ui.addItem( @@ -52,19 +63,13 @@ function updateAction(context: CommandContext, args: string): Promise { Date.now(), ); } - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); + + context.ui.addItem(historyItem, Date.now()); context.ui.setPendingItem(null); }); try { - context.ui.setPendingItem({ - type: MessageType.EXTENSIONS_LIST, - }); + context.ui.setPendingItem(historyItem); context.ui.dispatchExtensionStateUpdate({ type: 'SCHEDULE_UPDATE', @@ -77,7 +82,7 @@ function updateAction(context: CommandContext, args: string): Promise { }, }); if (names?.length) { - const extensions = context.services.config!.getExtensions(); + const extensions = listExtensions(context.services.config!); for (const name of names) { const extension = extensions.find( (extension) => extension.name === name, @@ -120,7 +125,9 @@ const updateExtensionsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: updateAction, completion: async (context, partialArg) => { - const extensions = context.services.config?.getExtensions() ?? []; + const extensions = context.services.config + ? listExtensions(context.services.config) + : []; const extensionNames = extensions.map((ext) => ext.name); const suggestions = extensionNames.filter((name) => name.startsWith(partialArg), diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 19c42d7e7a..7ec1f2da80 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -130,7 +130,9 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'compression' && ( )} - {itemForDisplay.type === 'extensions_list' && } + {itemForDisplay.type === 'extensions_list' && ( + + )} {itemForDisplay.type === 'tools_list' && ( ', () => { @@ -27,31 +47,25 @@ describe('', () => { }); const mockUIState = ( - extensions: unknown[], extensionsUpdateState: Map, ) => { mockUseUIState.mockReturnValue({ - commandContext: createMockCommandContext({ - services: { - config: { - getExtensions: () => extensions, - }, - }, - }), extensionsUpdateState, // Add other required properties from UIState if needed by the component } as never); }; it('should render "No extensions installed." if there are no extensions', () => { - mockUIState([], new Map()); - const { lastFrame } = render(); + mockUIState(new Map()); + const { lastFrame } = render(); expect(lastFrame()).toContain('No extensions installed.'); }); it('should render a list of extensions with their version and status', () => { - mockUIState(mockExtensions, new Map()); - const { lastFrame } = render(); + mockUIState(new Map()); + const { lastFrame } = render( + , + ); const output = lastFrame(); expect(output).toContain('ext-one (v1.0.0) - active'); expect(output).toContain('ext-two (v2.1.0) - active'); @@ -59,8 +73,10 @@ describe('', () => { }); it('should display "unknown state" if an extension has no update state', () => { - mockUIState([mockExtensions[0]], new Map()); - const { lastFrame } = render(); + mockUIState(new Map()); + const { lastFrame } = render( + , + ); expect(lastFrame()).toContain('(unknown state)'); }); @@ -94,8 +110,10 @@ describe('', () => { for (const { state, expectedText } of stateTestCases) { it(`should correctly display the state: ${state}`, () => { const updateState = new Map([[mockExtensions[0].name, state]]); - mockUIState([mockExtensions[0]], updateState); - const { lastFrame } = render(); + mockUIState(updateState); + const { lastFrame } = render( + , + ); expect(lastFrame()).toContain(expectedText); }); } diff --git a/packages/cli/src/ui/components/views/ExtensionsList.tsx b/packages/cli/src/ui/components/views/ExtensionsList.tsx index 3a78518c8f..e1ddf270f3 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.tsx @@ -4,15 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type React from 'react'; import { Box, Text } from 'ink'; import { useUIState } from '../../contexts/UIStateContext.js'; import { ExtensionUpdateState } from '../../state/extensions.js'; +import type { GeminiCLIExtension } from '@google/gemini-cli-core'; -export const ExtensionsList = () => { - const { commandContext, extensionsUpdateState } = useUIState(); - const allExtensions = commandContext.services.config!.getExtensions(); +interface ExtensionsList { + extensions: readonly GeminiCLIExtension[]; +} - if (allExtensions.length === 0) { +export const ExtensionsList: React.FC = ({ extensions }) => { + const { extensionsUpdateState } = useUIState(); + + if (extensions.length === 0) { return No extensions installed.; } @@ -20,7 +25,7 @@ export const ExtensionsList = () => { Installed extensions: - {allExtensions.map((ext) => { + {extensions.map((ext) => { const state = extensionsUpdateState.get(ext.name); const isActive = ext.isActive; const activeString = isActive ? 'active' : 'disabled'; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 5370574606..7a4c09f2cb 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -6,6 +6,7 @@ import type { CompressionStatus, + GeminiCLIExtension, MCPServerConfig, ThoughtSummary, ToolCallConfirmationDetails, @@ -163,6 +164,7 @@ export type HistoryItemCompression = HistoryItemBase & { export type HistoryItemExtensionsList = HistoryItemBase & { type: 'extensions_list'; + extensions: GeminiCLIExtension[]; }; export interface ChatDetail { diff --git a/packages/core/src/commands/extensions.test.ts b/packages/core/src/commands/extensions.test.ts new file mode 100644 index 0000000000..cc53123d59 --- /dev/null +++ b/packages/core/src/commands/extensions.test.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { listExtensions } from './extensions.js'; +import type { Config } from '../config/config.js'; + +describe('listExtensions', () => { + it('should call config.getExtensions and return the result', () => { + const mockExtensions = [{ name: 'ext1' }, { name: 'ext2' }]; + const mockConfig = { + getExtensions: vi.fn().mockReturnValue(mockExtensions), + } as unknown as Config; + + const result = listExtensions(mockConfig); + + expect(mockConfig.getExtensions).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockExtensions); + }); +}); diff --git a/packages/core/src/commands/extensions.ts b/packages/core/src/commands/extensions.ts new file mode 100644 index 0000000000..c18593e64c --- /dev/null +++ b/packages/core/src/commands/extensions.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; + +export function listExtensions(config: Config) { + return config.getExtensions(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9cb1662714..e2248b0c73 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,9 @@ export * from './policy/policy-engine.js'; export * from './confirmation-bus/types.js'; export * from './confirmation-bus/message-bus.js'; +// Export Commands logic +export * from './commands/extensions.js'; + // Export Core Logic export * from './core/client.js'; export * from './core/contentGenerator.js';