Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';

const SLOW_RENDER_MS = 200;

Expand Down Expand Up @@ -335,6 +336,11 @@ export async function main() {
});

setupUnhandledRejectionHandler();

const slashCommandConflictHandler = new SlashCommandConflictHandler();
slashCommandConflictHandler.start();
registerCleanup(() => slashCommandConflictHandler.stop());

const loadSettingsHandle = startupProfiler.start('load_settings');
const settings = loadSettings();
loadSettingsHandle?.end();
Expand Down
113 changes: 113 additions & 0 deletions packages/cli/src/services/CommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
}),
]),
);
});
});
59 changes: 56 additions & 3 deletions packages/cli/src/services/CommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
chrstnb marked this conversation as resolved.
losers: Array<{
command: SlashCommand;
renamedTo: string;
}>;
}

/**
* Orchestrates the discovery and loading of all slash commands for the CLI.
*
Expand All @@ -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.
Expand Down Expand Up @@ -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;

Expand All @@ -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, {
Expand All @@ -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);
}

/**
Expand All @@ -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 changes: 54 additions & 0 deletions packages/cli/src/services/SlashCommandConflictHandler.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
}
Loading
Loading