mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 14:40:52 -07:00
Warn user when we overwrite a command due to conflict with extensions
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -316,6 +316,7 @@ function useCommandSuggestions(
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
commandKind: cmd.kind,
|
||||
extensionName: cmd.extensionName,
|
||||
}));
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
|
||||
Reference in New Issue
Block a user