From ced2f2873d46c2644753b186e0944be6ec823537 Mon Sep 17 00:00:00 2001 From: Christine Betts Date: Wed, 21 Jan 2026 18:01:12 -0500 Subject: [PATCH] Warn user when we overwrite a command due to conflict with extensions --- packages/cli/src/config/extension-manager.ts | 85 +++++++++++++++++++ .../cli/src/services/CommandService.test.ts | 29 ++++++- packages/cli/src/services/CommandService.ts | 18 +++- .../cli/src/services/FileCommandLoader.ts | 2 +- .../src/ui/components/SuggestionsDisplay.tsx | 10 ++- .../cli/src/ui/hooks/useSlashCompletion.ts | 1 + 6 files changed, 140 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 8dbbfe305b..9eb3b6a70b 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -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 { + 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(); + 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 { diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 31dfdcace8..29de25397e 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -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); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 0e29a81d00..c3049ead22 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -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(); + + /** + * 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; } diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 688fb0ce0e..f53435fb0d 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -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.", diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 96eb554076..d0c311b05c 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -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), diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 7809d6cf0f..398dbb903f 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -316,6 +316,7 @@ function useCommandSuggestions( value: cmd.name, description: cmd.description, commandKind: cmd.kind, + extensionName: cmd.extensionName, })); setSuggestions(finalSuggestions);