Warn user when we overwrite a command due to conflict with extensions

This commit is contained in:
Christine Betts
2026-01-21 18:01:12 -05:00
parent 1033550f78
commit ced2f2873d
6 changed files with 140 additions and 5 deletions

View File

@@ -68,6 +68,10 @@ import {
ExtensionSettingScope,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { glob } from 'glob';
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
import { McpPromptLoader } from '../services/McpPromptLoader.js';
import { FileCommandLoader } from '../services/FileCommandLoader.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
@@ -236,6 +240,9 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
const newExtensionName = newExtensionConfig.name;
await this.checkCommandConflicts(localSourcePath, newExtensionName);
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
);
@@ -899,6 +906,84 @@ Would you like to attempt to install via "git clone" instead?`,
}
await this.maybeStartExtension(extension);
}
private async checkCommandConflicts(
localSourcePath: string,
extensionName: string,
): Promise<void> {
const abortController = new AbortController();
const signal = abortController.signal;
// 1. Get current commands
const currentLoaders = [
new McpPromptLoader(this.config ?? null),
new BuiltinCommandLoader(this.config ?? null),
new FileCommandLoader(this.config ?? null),
];
const currentCommandsResults = await Promise.allSettled(
currentLoaders.map((l) => l.loadCommands(signal)),
);
const currentCommandNames = new Set<string>();
for (const result of currentCommandsResults) {
if (result.status === 'fulfilled') {
result.value.forEach((cmd) => {
// If it's an update, don't count existing commands from the SAME extension as conflicts
if (cmd.extensionName !== extensionName) {
currentCommandNames.add(cmd.name);
}
});
}
}
// 2. Get commands from the new/updated extension
const extensionCommandsDir = path.join(localSourcePath, 'commands');
if (!fs.existsSync(extensionCommandsDir)) {
return;
}
const files = await glob('**/*.toml', {
cwd: extensionCommandsDir,
nodir: true,
dot: true,
follow: true,
});
const conflicts: Array<{
commandName: string;
renamedName: string;
}> = [];
for (const file of files) {
const relativePath = file.substring(0, file.length - 5); // length of '.toml'
const baseCommandName = relativePath
.split(path.sep)
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
if (currentCommandNames.has(baseCommandName)) {
conflicts.push({
commandName: baseCommandName,
renamedName: `${extensionName}.${baseCommandName}`,
});
}
}
if (conflicts.length > 0) {
const conflictList = conflicts
.map(
(c) =>
` - '/${c.commandName}' (will be renamed to '/${c.renamedName}')`,
)
.join('\n');
const warning = `WARNING: Installing extension '${extensionName}' will cause the following command conflicts:\n${conflictList}\n\nDo you want to continue installation?`;
if (!(await this.requestConsent(warning))) {
throw new Error('Installation cancelled due to command conflicts.');
}
}
}
}
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {

View File

@@ -8,7 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CommandService } from './CommandService.js';
import { type ICommandLoader } from './types.js';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
name,
@@ -37,6 +37,7 @@ class MockCommandLoader implements ICommandLoader {
describe('CommandService', () => {
beforeEach(() => {
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
CommandService.clearEmittedFeedbacksForTest();
});
afterEach(() => {
@@ -237,6 +238,32 @@ describe('CommandService', () => {
expect(syncExtension?.extensionName).toBe('git-helper');
});
it('should emit feedback when an extension command is renamed', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
description: '[firebase] Deploy to Firebase',
};
const mockLoader1 = new MockCommandLoader([builtinCommand]);
const mockLoader2 = new MockCommandLoader([extensionCommand]);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
await CommandService.create(
[mockLoader1, mockLoader2],
new AbortController().signal,
);
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining(
"Extension command '/deploy' from 'firebase' was renamed to '/firebase.deploy'",
),
);
});
it('should handle user/project command override correctly', async () => {
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
const userCommand = createMockCommand('help', CommandKind.FILE);

View File

@@ -4,7 +4,7 @@
* 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';
@@ -20,6 +20,16 @@ import type { ICommandLoader } from './types.js';
* system to be extended with new sources without modifying the service itself.
*/
export class CommandService {
private static emittedFeedbacks = new Set<string>();
/**
* Clears the set of emitted feedback messages.
* This should ONLY be used in tests to ensure isolation between test cases.
*/
static clearEmittedFeedbacksForTest(): void {
CommandService.emittedFeedbacks.clear();
}
/**
* Private constructor to enforce the use of the async factory.
* @param commands A readonly array of the fully loaded and de-duplicated commands.
@@ -77,6 +87,12 @@ export class CommandService {
suffix++;
}
const feedbackMsg = `Extension command '/${cmd.name}' from '${cmd.extensionName}' was renamed to '/${renamedName}' due to a conflict with an existing command.`;
if (!CommandService.emittedFeedbacks.has(feedbackMsg)) {
coreEvents.emitFeedback('info', feedbackMsg);
CommandService.emittedFeedbacks.add(feedbackMsg);
}
finalName = renamedName;
}

View File

@@ -44,7 +44,7 @@ interface CommandDirectory {
* Defines the Zod schema for a command definition file. This serves as the
* single source of truth for both validation and type inference.
*/
const TomlCommandDefSchema = z.object({
export const TomlCommandDefSchema = z.object({
prompt: z.string({
required_error: "The 'prompt' field is required.",
invalid_type_error: "The 'prompt' field must be a string.",

View File

@@ -15,6 +15,7 @@ export interface Suggestion {
description?: string;
matchedIndex?: number;
commandKind?: CommandKind;
extensionName?: string;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
@@ -65,8 +66,13 @@ export function SuggestionsDisplay({
[CommandKind.AGENT]: ' [Agent]',
};
const getFullLabel = (s: Suggestion) =>
s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '');
const getFullLabel = (s: Suggestion) => {
let label = s.label;
if (s.commandKind && COMMAND_KIND_SUFFIX[s.commandKind]) {
label += COMMAND_KIND_SUFFIX[s.commandKind];
}
return label;
};
const maxLabelLength = Math.max(
...suggestions.map((s) => getFullLabel(s).length),

View File

@@ -316,6 +316,7 @@ function useCommandSuggestions(
value: cmd.name,
description: cmd.description,
commandKind: cmd.kind,
extensionName: cmd.extensionName,
}));
setSuggestions(finalSuggestions);