mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ced2f2873d |
@@ -68,6 +68,10 @@ import {
|
|||||||
ExtensionSettingScope,
|
ExtensionSettingScope,
|
||||||
} from './extensions/extensionSettings.js';
|
} from './extensions/extensionSettings.js';
|
||||||
import type { EventEmitter } from 'node:stream';
|
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 {
|
interface ExtensionManagerParams {
|
||||||
enabledExtensionOverrides?: string[];
|
enabledExtensionOverrides?: string[];
|
||||||
@@ -236,6 +240,9 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||||
|
|
||||||
const newExtensionName = newExtensionConfig.name;
|
const newExtensionName = newExtensionConfig.name;
|
||||||
|
|
||||||
|
await this.checkCommandConflicts(localSourcePath, newExtensionName);
|
||||||
|
|
||||||
const previous = this.getExtensions().find(
|
const previous = this.getExtensions().find(
|
||||||
(installed) => installed.name === newExtensionName,
|
(installed) => installed.name === newExtensionName,
|
||||||
);
|
);
|
||||||
@@ -899,6 +906,84 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
}
|
}
|
||||||
await this.maybeStartExtension(extension);
|
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 {
|
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|||||||
import { CommandService } from './CommandService.js';
|
import { CommandService } from './CommandService.js';
|
||||||
import { type ICommandLoader } from './types.js';
|
import { type ICommandLoader } from './types.js';
|
||||||
import { CommandKind, type SlashCommand } from '../ui/commands/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 => ({
|
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
|
||||||
name,
|
name,
|
||||||
@@ -37,6 +37,7 @@ class MockCommandLoader implements ICommandLoader {
|
|||||||
describe('CommandService', () => {
|
describe('CommandService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||||
|
CommandService.clearEmittedFeedbacksForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -237,6 +238,32 @@ describe('CommandService', () => {
|
|||||||
expect(syncExtension?.extensionName).toBe('git-helper');
|
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 () => {
|
it('should handle user/project command override correctly', async () => {
|
||||||
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
|
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
|
||||||
const userCommand = createMockCommand('help', CommandKind.FILE);
|
const userCommand = createMockCommand('help', CommandKind.FILE);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { SlashCommand } from '../ui/commands/types.js';
|
||||||
import type { ICommandLoader } from './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.
|
* system to be extended with new sources without modifying the service itself.
|
||||||
*/
|
*/
|
||||||
export class CommandService {
|
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.
|
* Private constructor to enforce the use of the async factory.
|
||||||
* @param commands A readonly array of the fully loaded and de-duplicated commands.
|
* @param commands A readonly array of the fully loaded and de-duplicated commands.
|
||||||
@@ -77,6 +87,12 @@ export class CommandService {
|
|||||||
suffix++;
|
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;
|
finalName = renamedName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface CommandDirectory {
|
|||||||
* Defines the Zod schema for a command definition file. This serves as the
|
* Defines the Zod schema for a command definition file. This serves as the
|
||||||
* single source of truth for both validation and type inference.
|
* single source of truth for both validation and type inference.
|
||||||
*/
|
*/
|
||||||
const TomlCommandDefSchema = z.object({
|
export const TomlCommandDefSchema = z.object({
|
||||||
prompt: z.string({
|
prompt: z.string({
|
||||||
required_error: "The 'prompt' field is required.",
|
required_error: "The 'prompt' field is required.",
|
||||||
invalid_type_error: "The 'prompt' field must be a string.",
|
invalid_type_error: "The 'prompt' field must be a string.",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Suggestion {
|
|||||||
description?: string;
|
description?: string;
|
||||||
matchedIndex?: number;
|
matchedIndex?: number;
|
||||||
commandKind?: CommandKind;
|
commandKind?: CommandKind;
|
||||||
|
extensionName?: string;
|
||||||
}
|
}
|
||||||
interface SuggestionsDisplayProps {
|
interface SuggestionsDisplayProps {
|
||||||
suggestions: Suggestion[];
|
suggestions: Suggestion[];
|
||||||
@@ -65,8 +66,13 @@ export function SuggestionsDisplay({
|
|||||||
[CommandKind.AGENT]: ' [Agent]',
|
[CommandKind.AGENT]: ' [Agent]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFullLabel = (s: Suggestion) =>
|
const getFullLabel = (s: Suggestion) => {
|
||||||
s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '');
|
let label = s.label;
|
||||||
|
if (s.commandKind && COMMAND_KIND_SUFFIX[s.commandKind]) {
|
||||||
|
label += COMMAND_KIND_SUFFIX[s.commandKind];
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
const maxLabelLength = Math.max(
|
const maxLabelLength = Math.max(
|
||||||
...suggestions.map((s) => getFullLabel(s).length),
|
...suggestions.map((s) => getFullLabel(s).length),
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ function useCommandSuggestions(
|
|||||||
value: cmd.name,
|
value: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
commandKind: cmd.kind,
|
commandKind: cmd.kind,
|
||||||
|
extensionName: cmd.extensionName,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setSuggestions(finalSuggestions);
|
setSuggestions(finalSuggestions);
|
||||||
|
|||||||
Reference in New Issue
Block a user