mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 10:01:29 -07:00
Show notification when there's a conflict with an extensions command (#17890)
This commit is contained in:
@@ -350,4 +350,117 @@ describe('CommandService', () => {
|
||||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
|
||||
});
|
||||
|
||||
it('should report conflicts via getConflicts', async () => {
|
||||
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
|
||||
const extensionCommand = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'firebase',
|
||||
};
|
||||
|
||||
const mockLoader = new MockCommandLoader([
|
||||
builtinCommand,
|
||||
extensionCommand,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const conflicts = service.getConflicts();
|
||||
expect(conflicts).toHaveLength(1);
|
||||
|
||||
expect(conflicts[0]).toMatchObject({
|
||||
name: 'deploy',
|
||||
winner: builtinCommand,
|
||||
losers: [
|
||||
{
|
||||
renamedTo: 'firebase.deploy',
|
||||
command: expect.objectContaining({
|
||||
name: 'deploy',
|
||||
extensionName: 'firebase',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should report extension vs extension conflicts correctly', async () => {
|
||||
// Both extensions try to register 'deploy'
|
||||
const extension1Command = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'firebase',
|
||||
};
|
||||
const extension2Command = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'aws',
|
||||
};
|
||||
|
||||
const mockLoader = new MockCommandLoader([
|
||||
extension1Command,
|
||||
extension2Command,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const conflicts = service.getConflicts();
|
||||
expect(conflicts).toHaveLength(1);
|
||||
|
||||
expect(conflicts[0]).toMatchObject({
|
||||
name: 'deploy',
|
||||
winner: expect.objectContaining({
|
||||
name: 'deploy',
|
||||
extensionName: 'firebase',
|
||||
}),
|
||||
losers: [
|
||||
{
|
||||
renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list
|
||||
command: expect.objectContaining({
|
||||
name: 'deploy',
|
||||
extensionName: 'aws',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should report multiple conflicts for the same command name', async () => {
|
||||
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
|
||||
const ext1 = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'ext1',
|
||||
};
|
||||
const ext2 = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'ext2',
|
||||
};
|
||||
|
||||
const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const conflicts = service.getConflicts();
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0].name).toBe('deploy');
|
||||
expect(conflicts[0].losers).toHaveLength(2);
|
||||
expect(conflicts[0].losers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
renamedTo: 'ext1.deploy',
|
||||
command: expect.objectContaining({ extensionName: 'ext1' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
renamedTo: 'ext2.deploy',
|
||||
command: expect.objectContaining({ extensionName: 'ext2' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import type { SlashCommand } from '../ui/commands/types.js';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
|
||||
export interface CommandConflict {
|
||||
name: string;
|
||||
winner: SlashCommand;
|
||||
losers: Array<{
|
||||
command: SlashCommand;
|
||||
renamedTo: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the discovery and loading of all slash commands for the CLI.
|
||||
*
|
||||
@@ -23,8 +32,12 @@ 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.
|
||||
* @param conflicts A readonly array of conflicts that occurred during loading.
|
||||
*/
|
||||
private constructor(private readonly commands: readonly SlashCommand[]) {}
|
||||
private constructor(
|
||||
private readonly commands: readonly SlashCommand[],
|
||||
private readonly conflicts: readonly CommandConflict[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Asynchronously creates and initializes a new CommandService instance.
|
||||
@@ -63,11 +76,14 @@ export class CommandService {
|
||||
}
|
||||
|
||||
const commandMap = new Map<string, SlashCommand>();
|
||||
const conflictsMap = new Map<string, CommandConflict>();
|
||||
|
||||
for (const cmd of allCommands) {
|
||||
let finalName = cmd.name;
|
||||
|
||||
// Extension commands get renamed if they conflict with existing commands
|
||||
if (cmd.extensionName && commandMap.has(cmd.name)) {
|
||||
const winner = commandMap.get(cmd.name)!;
|
||||
let renamedName = `${cmd.extensionName}.${cmd.name}`;
|
||||
let suffix = 1;
|
||||
|
||||
@@ -78,6 +94,19 @@ export class CommandService {
|
||||
}
|
||||
|
||||
finalName = renamedName;
|
||||
|
||||
if (!conflictsMap.has(cmd.name)) {
|
||||
conflictsMap.set(cmd.name, {
|
||||
name: cmd.name,
|
||||
winner,
|
||||
losers: [],
|
||||
});
|
||||
}
|
||||
|
||||
conflictsMap.get(cmd.name)!.losers.push({
|
||||
command: cmd,
|
||||
renamedTo: finalName,
|
||||
});
|
||||
}
|
||||
|
||||
commandMap.set(finalName, {
|
||||
@@ -86,8 +115,23 @@ export class CommandService {
|
||||
});
|
||||
}
|
||||
|
||||
const conflicts = Array.from(conflictsMap.values());
|
||||
if (conflicts.length > 0) {
|
||||
coreEvents.emitSlashCommandConflicts(
|
||||
conflicts.flatMap((c) =>
|
||||
c.losers.map((l) => ({
|
||||
name: c.name,
|
||||
renamedTo: l.renamedTo,
|
||||
loserExtensionName: l.command.extensionName,
|
||||
winnerExtensionName: c.winner.extensionName,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const finalCommands = Object.freeze(Array.from(commandMap.values()));
|
||||
return new CommandService(finalCommands);
|
||||
const finalConflicts = Object.freeze(conflicts);
|
||||
return new CommandService(finalCommands, finalConflicts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,4 +145,13 @@ export class CommandService {
|
||||
getCommands(): readonly SlashCommand[] {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of conflicts that occurred during command loading.
|
||||
*
|
||||
* @returns A readonly array of command conflicts.
|
||||
*/
|
||||
getConflicts(): readonly CommandConflict[] {
|
||||
return this.conflicts;
|
||||
}
|
||||
}
|
||||
|
||||
54
packages/cli/src/services/SlashCommandConflictHandler.ts
Normal file
54
packages/cli/src/services/SlashCommandConflictHandler.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
type SlashCommandConflictsPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export class SlashCommandConflictHandler {
|
||||
private notifiedConflicts = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
this.handleConflicts = this.handleConflicts.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
coreEvents.on(CoreEvent.SlashCommandConflicts, this.handleConflicts);
|
||||
}
|
||||
|
||||
stop() {
|
||||
coreEvents.off(CoreEvent.SlashCommandConflicts, this.handleConflicts);
|
||||
}
|
||||
|
||||
private handleConflicts(payload: SlashCommandConflictsPayload) {
|
||||
const newConflicts = payload.conflicts.filter((c) => {
|
||||
const key = `${c.name}:${c.loserExtensionName}`;
|
||||
if (this.notifiedConflicts.has(key)) {
|
||||
return false;
|
||||
}
|
||||
this.notifiedConflicts.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newConflicts.length > 0) {
|
||||
const conflictMessages = newConflicts
|
||||
.map((c) => {
|
||||
const winnerSource = c.winnerExtensionName
|
||||
? `extension '${c.winnerExtensionName}'`
|
||||
: 'an existing command';
|
||||
return `- Command '/${c.name}' from extension '${c.loserExtensionName}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
`Command conflicts detected:\n${conflictMessages}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user