mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Adds listCommands endpoint to a2a server (#12604)
Co-authored-by: Juanda <jdgarrido@google.com> Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
This commit is contained in:
@@ -5,27 +5,44 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Command } from './types.js';
|
||||
|
||||
describe('CommandRegistry', () => {
|
||||
const mockListExtensionsCommandInstance = {
|
||||
names: ['extensions', 'extensions list'],
|
||||
const mockListExtensionsCommandInstance: Command = {
|
||||
name: 'extensions list',
|
||||
description: 'Lists all installed extensions.',
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const mockListExtensionsCommand = vi.fn(
|
||||
() => mockListExtensionsCommandInstance,
|
||||
);
|
||||
|
||||
const mockExtensionsCommandInstance: Command = {
|
||||
name: 'extensions',
|
||||
description: 'Manage extensions.',
|
||||
execute: vi.fn(),
|
||||
subCommands: [mockListExtensionsCommandInstance],
|
||||
};
|
||||
const mockExtensionsCommand = vi.fn(() => mockExtensionsCommandInstance);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('./list-extensions', () => ({
|
||||
vi.doMock('./extensions.js', () => ({
|
||||
ExtensionsCommand: mockExtensionsCommand,
|
||||
ListExtensionsCommand: mockListExtensionsCommand,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should register ListExtensionsCommand on initialization', async () => {
|
||||
it('should register ExtensionsCommand on initialization', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
expect(mockListExtensionsCommand).toHaveBeenCalled();
|
||||
expect(mockExtensionsCommand).toHaveBeenCalled();
|
||||
const command = commandRegistry.get('extensions');
|
||||
expect(command).toBe(mockExtensionsCommandInstance);
|
||||
});
|
||||
|
||||
it('should register sub commands on initialization', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const command = commandRegistry.get('extensions list');
|
||||
expect(command).toBe(mockListExtensionsCommandInstance);
|
||||
});
|
||||
|
||||
@@ -37,12 +54,65 @@ describe('CommandRegistry', () => {
|
||||
|
||||
it('register() should register a new command', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const mockCommand = {
|
||||
names: ['test-command'],
|
||||
const mockCommand: Command = {
|
||||
name: 'test-command',
|
||||
description: '',
|
||||
execute: vi.fn(),
|
||||
};
|
||||
commandRegistry.register(mockCommand);
|
||||
const command = commandRegistry.get('test-command');
|
||||
expect(command).toBe(mockCommand);
|
||||
});
|
||||
|
||||
it('register() should register a nested command', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const mockSubSubCommand: Command = {
|
||||
name: 'test-command-sub-sub',
|
||||
description: '',
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const mockSubCommand: Command = {
|
||||
name: 'test-command-sub',
|
||||
description: '',
|
||||
execute: vi.fn(),
|
||||
subCommands: [mockSubSubCommand],
|
||||
};
|
||||
const mockCommand: Command = {
|
||||
name: 'test-command',
|
||||
description: '',
|
||||
execute: vi.fn(),
|
||||
subCommands: [mockSubCommand],
|
||||
};
|
||||
commandRegistry.register(mockCommand);
|
||||
|
||||
const command = commandRegistry.get('test-command');
|
||||
const subCommand = commandRegistry.get('test-command-sub');
|
||||
const subSubCommand = commandRegistry.get('test-command-sub-sub');
|
||||
|
||||
expect(command).toBe(mockCommand);
|
||||
expect(subCommand).toBe(mockSubCommand);
|
||||
expect(subSubCommand).toBe(mockSubSubCommand);
|
||||
});
|
||||
|
||||
it('register() should not enter an infinite loop with a cyclic command', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const mockCommand: Command = {
|
||||
name: 'cyclic-command',
|
||||
description: '',
|
||||
subCommands: [],
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
mockCommand.subCommands?.push(mockCommand); // Create cycle
|
||||
|
||||
commandRegistry.register(mockCommand);
|
||||
|
||||
expect(commandRegistry.get('cyclic-command')).toBe(mockCommand);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Command cyclic-command already registered. Skipping.',
|
||||
);
|
||||
// If the test finishes, it means we didn't get into an infinite loop.
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,30 +4,36 @@
|
||||
* 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<unknown>;
|
||||
}
|
||||
import { ExtensionsCommand } from './extensions.js';
|
||||
import type { Command } from './types.js';
|
||||
|
||||
class CommandRegistry {
|
||||
private readonly commands = new Map<string, Command>();
|
||||
|
||||
constructor() {
|
||||
this.register(new ListExtensionsCommand());
|
||||
this.register(new ExtensionsCommand());
|
||||
}
|
||||
|
||||
register(command: Command) {
|
||||
for (const name of command.names) {
|
||||
this.commands.set(name, command);
|
||||
if (this.commands.has(command.name)) {
|
||||
console.warn(`Command ${command.name} already registered. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.commands.set(command.name, command);
|
||||
|
||||
for (const subCommand of command.subCommands ?? []) {
|
||||
this.register(subCommand);
|
||||
}
|
||||
}
|
||||
|
||||
get(commandName: string): Command | undefined {
|
||||
return this.commands.get(commandName);
|
||||
}
|
||||
|
||||
getAllCommands(): Command[] {
|
||||
return [...this.commands.values()];
|
||||
}
|
||||
}
|
||||
|
||||
export const commandRegistry = new CommandRegistry();
|
||||
|
||||
87
packages/a2a-server/src/commands/extensions.test.ts
Normal file
87
packages/a2a-server/src/commands/extensions.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ExtensionsCommand, ListExtensionsCommand } from './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<typeof import('@google/gemini-cli-core')>();
|
||||
|
||||
return {
|
||||
...original,
|
||||
listExtensions: mockListExtensions,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionsCommand', () => {
|
||||
it('should have the correct name', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.name).toEqual('extensions');
|
||||
});
|
||||
|
||||
it('should have the correct description', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.description).toEqual('Manage extensions.');
|
||||
});
|
||||
|
||||
it('should have "extensions list" as a subcommand', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.subCommands.map((c) => c.name)).toContain('extensions list');
|
||||
});
|
||||
|
||||
it('should be a top-level command', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.topLevel).toBe(true);
|
||||
});
|
||||
|
||||
it('should default to listing extensions', async () => {
|
||||
const command = new ExtensionsCommand();
|
||||
const mockConfig = {} as Config;
|
||||
const mockExtensions = [{ name: 'ext1' }];
|
||||
mockListExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
const result = await command.execute(mockConfig, []);
|
||||
|
||||
expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
|
||||
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListExtensionsCommand', () => {
|
||||
it('should have the correct name', () => {
|
||||
const command = new ListExtensionsCommand();
|
||||
expect(command.name).toEqual('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({ name: 'extensions list', data: mockExtensions });
|
||||
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
it('should return a message when no extensions are installed', async () => {
|
||||
const command = new ListExtensionsCommand();
|
||||
const mockConfig = {} as Config;
|
||||
mockListExtensions.mockReturnValue([]);
|
||||
|
||||
const result = await command.execute(mockConfig, []);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions list',
|
||||
data: 'No extensions installed.',
|
||||
});
|
||||
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
37
packages/a2a-server/src/commands/extensions.ts
Normal file
37
packages/a2a-server/src/commands/extensions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { listExtensions, type Config } from '@google/gemini-cli-core';
|
||||
import type { Command, CommandExecutionResponse } from './types.js';
|
||||
|
||||
export class ExtensionsCommand implements Command {
|
||||
readonly name = 'extensions';
|
||||
readonly description = 'Manage extensions.';
|
||||
readonly subCommands = [new ListExtensionsCommand()];
|
||||
readonly topLevel = true;
|
||||
|
||||
async execute(
|
||||
config: Config,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
return new ListExtensionsCommand().execute(config, _);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListExtensionsCommand implements Command {
|
||||
readonly name = 'extensions list';
|
||||
readonly description = 'Lists all installed extensions.';
|
||||
|
||||
async execute(
|
||||
config: Config,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensions = listExtensions(config);
|
||||
const data = extensions.length ? extensions : 'No extensions installed.';
|
||||
|
||||
return { name: this.name, data };
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* @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<typeof import('@google/gemini-cli-core')>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* @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<unknown> {
|
||||
return listExtensions(config);
|
||||
}
|
||||
}
|
||||
28
packages/a2a-server/src/commands/types.ts
Normal file
28
packages/a2a-server/src/commands/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export interface CommandArgument {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly arguments?: CommandArgument[];
|
||||
readonly subCommands?: Command[];
|
||||
readonly topLevel?: boolean;
|
||||
|
||||
execute(config: Config, args: string[]): Promise<CommandExecutionResponse>;
|
||||
}
|
||||
|
||||
export interface CommandExecutionResponse {
|
||||
readonly name: string;
|
||||
readonly data: unknown;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createApp } from './app.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import {
|
||||
assertUniqueFinalEventIsLast,
|
||||
assertTaskCreationAndWorkingStatus,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
createMockConfig,
|
||||
} from '../utils/testing_utils.js';
|
||||
import { MockTool } from '@google/gemini-cli-core';
|
||||
import type { Command } from '../commands/types.js';
|
||||
|
||||
const mockToolConfirmationFn = async () =>
|
||||
({}) as unknown as ToolCallConfirmationDetails;
|
||||
@@ -827,6 +829,104 @@ describe('E2E Tests', () => {
|
||||
expect(thoughtEvent.metadata?.['traceId']).toBe(traceId);
|
||||
});
|
||||
|
||||
describe('/listCommands', () => {
|
||||
it('should return a list of top-level commands', async () => {
|
||||
const mockCommands = [
|
||||
{
|
||||
name: 'test-command',
|
||||
description: 'A test command',
|
||||
topLevel: true,
|
||||
arguments: [{ name: 'arg1', description: 'Argument 1' }],
|
||||
subCommands: [
|
||||
{
|
||||
name: 'sub-command',
|
||||
description: 'A sub command',
|
||||
topLevel: false,
|
||||
execute: vi.fn(),
|
||||
},
|
||||
],
|
||||
execute: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'another-command',
|
||||
description: 'Another test command',
|
||||
topLevel: true,
|
||||
execute: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'not-top-level',
|
||||
description: 'Not a top level command',
|
||||
topLevel: false,
|
||||
execute: vi.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const getAllCommandsSpy = vi
|
||||
.spyOn(commandRegistry, 'getAllCommands')
|
||||
.mockReturnValue(mockCommands);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.get('/listCommands').expect(200);
|
||||
|
||||
expect(res.body).toEqual({
|
||||
commands: [
|
||||
{
|
||||
name: 'test-command',
|
||||
description: 'A test command',
|
||||
arguments: [{ name: 'arg1', description: 'Argument 1' }],
|
||||
subCommands: [
|
||||
{
|
||||
name: 'sub-command',
|
||||
description: 'A sub command',
|
||||
arguments: [],
|
||||
subCommands: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'another-command',
|
||||
description: 'Another test command',
|
||||
arguments: [],
|
||||
subCommands: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getAllCommandsSpy).toHaveBeenCalledOnce();
|
||||
getAllCommandsSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle cyclic commands gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const cyclicCommand: Command = {
|
||||
name: 'cyclic-command',
|
||||
description: 'A cyclic command',
|
||||
topLevel: true,
|
||||
execute: vi.fn(),
|
||||
subCommands: [],
|
||||
};
|
||||
cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle
|
||||
|
||||
const getAllCommandsSpy = vi
|
||||
.spyOn(commandRegistry, 'getAllCommands')
|
||||
.mockReturnValue([cyclicCommand]);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.get('/listCommands').expect(200);
|
||||
|
||||
expect(res.body.commands[0].name).toBe('cyclic-command');
|
||||
expect(res.body.commands[0].subCommands).toEqual([]);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Command cyclic-command already inserted in the response, skipping',
|
||||
);
|
||||
|
||||
getAllCommandsSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/executeCommand', () => {
|
||||
const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }];
|
||||
|
||||
@@ -846,7 +946,10 @@ describe('E2E Tests', () => {
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
expect(res.body).toEqual(mockExtensions);
|
||||
expect(res.body).toEqual({
|
||||
name: 'extensions list',
|
||||
data: mockExtensions,
|
||||
});
|
||||
expect(getExtensionsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@ import { loadSettings } from '../config/settings.js';
|
||||
import { loadExtensions } from '../config/extension.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import { SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
|
||||
type CommandResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
arguments: CommandArgument[];
|
||||
subCommands: CommandResponse[];
|
||||
};
|
||||
|
||||
const coderAgentCard: AgentCard = {
|
||||
name: 'Gemini SDLC Agent',
|
||||
@@ -167,6 +175,48 @@ export async function createApp() {
|
||||
}
|
||||
});
|
||||
|
||||
expressApp.get('/listCommands', (req, res) => {
|
||||
try {
|
||||
const transformCommand = (
|
||||
command: Command,
|
||||
visited: string[],
|
||||
): CommandResponse | undefined => {
|
||||
const commandName = command.name;
|
||||
if (visited.includes(commandName)) {
|
||||
console.warn(
|
||||
`Command ${commandName} already inserted in the response, skipping`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
arguments: command.arguments ?? [],
|
||||
subCommands: (command.subCommands ?? [])
|
||||
.map((subCommand) =>
|
||||
transformCommand(subCommand, visited.concat(commandName)),
|
||||
)
|
||||
.filter(
|
||||
(subCommand): subCommand is CommandResponse => !!subCommand,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const commands = commandRegistry
|
||||
.getAllCommands()
|
||||
.filter((command) => command.topLevel)
|
||||
.map((command) => transformCommand(command, []));
|
||||
|
||||
return res.status(200).json({ commands });
|
||||
} catch (e) {
|
||||
logger.error('Error executing /listCommands:', e);
|
||||
const errorMessage =
|
||||
e instanceof Error ? e.message : 'Unknown error listing commands';
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user