Skip to content

Commit 4513b4a

Browse files
authored
feat(cli): implement dot-prefixing for slash command conflicts (#20979)
1 parent 76caf61 commit 4513b4a

15 files changed

Lines changed: 852 additions & 762 deletions

packages/cli/src/services/CommandService.test.ts

Lines changed: 61 additions & 423 deletions
Large diffs are not rendered by default.

packages/cli/src/services/CommandService.ts

Lines changed: 53 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,8 @@
66

77
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
88
import type { SlashCommand } from '../ui/commands/types.js';
9-
import type { ICommandLoader } from './types.js';
10-
11-
export interface CommandConflict {
12-
name: string;
13-
winner: SlashCommand;
14-
losers: Array<{
15-
command: SlashCommand;
16-
renamedTo: string;
17-
}>;
18-
}
9+
import type { ICommandLoader, CommandConflict } from './types.js';
10+
import { SlashCommandResolver } from './SlashCommandResolver.js';
1911

2012
/**
2113
* Orchestrates the discovery and loading of all slash commands for the CLI.
@@ -24,9 +16,9 @@ export interface CommandConflict {
2416
* with an array of `ICommandLoader` instances, each responsible for fetching
2517
* commands from a specific source (e.g., built-in code, local files).
2618
*
27-
* The CommandService is responsible for invoking these loaders, aggregating their
28-
* results, and resolving any name conflicts. This architecture allows the command
29-
* system to be extended with new sources without modifying the service itself.
19+
* It uses a delegating resolver to reconcile name conflicts, ensuring that
20+
* all commands are uniquely addressable via source-specific prefixes while
21+
* allowing built-in commands to retain their primary names.
3022
*/
3123
export class CommandService {
3224
/**
@@ -42,96 +34,71 @@ export class CommandService {
4234
/**
4335
* Asynchronously creates and initializes a new CommandService instance.
4436
*
45-
* This factory method orchestrates the entire command loading process. It
46-
* runs all provided loaders in parallel, aggregates their results, handles
47-
* name conflicts for extension commands by renaming them, and then returns a
48-
* fully constructed `CommandService` instance.
37+
* This factory method orchestrates the loading process and delegates
38+
* conflict resolution to the SlashCommandResolver.
4939
*
50-
* Conflict resolution:
51-
* - Extension commands that conflict with existing commands are renamed to
52-
* `extensionName.commandName`
53-
* - Non-extension commands (built-in, user, project) override earlier commands
54-
* with the same name based on loader order
55-
*
56-
* @param loaders An array of objects that conform to the `ICommandLoader`
57-
* interface. Built-in commands should come first, followed by FileCommandLoader.
58-
* @param signal An AbortSignal to cancel the loading process.
59-
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
40+
* @param loaders An array of loaders to fetch commands from.
41+
* @param signal An AbortSignal to allow cancellation.
42+
* @returns A promise that resolves to a fully initialized CommandService.
6043
*/
6144
static async create(
6245
loaders: ICommandLoader[],
6346
signal: AbortSignal,
6447
): Promise<CommandService> {
48+
const allCommands = await this.loadAllCommands(loaders, signal);
49+
const { finalCommands, conflicts } =
50+
SlashCommandResolver.resolve(allCommands);
51+
52+
if (conflicts.length > 0) {
53+
this.emitConflictEvents(conflicts);
54+
}
55+
56+
return new CommandService(
57+
Object.freeze(finalCommands),
58+
Object.freeze(conflicts),
59+
);
60+
}
61+
62+
/**
63+
* Invokes all loaders in parallel and flattens the results.
64+
*/
65+
private static async loadAllCommands(
66+
loaders: ICommandLoader[],
67+
signal: AbortSignal,
68+
): Promise<SlashCommand[]> {
6569
const results = await Promise.allSettled(
6670
loaders.map((loader) => loader.loadCommands(signal)),
6771
);
6872

69-
const allCommands: SlashCommand[] = [];
73+
const commands: SlashCommand[] = [];
7074
for (const result of results) {
7175
if (result.status === 'fulfilled') {
72-
allCommands.push(...result.value);
76+
commands.push(...result.value);
7377
} else {
7478
debugLogger.debug('A command loader failed:', result.reason);
7579
}
7680
}
81+
return commands;
82+
}
7783

78-
const commandMap = new Map<string, SlashCommand>();
79-
const conflictsMap = new Map<string, CommandConflict>();
80-
81-
for (const cmd of allCommands) {
82-
let finalName = cmd.name;
83-
84-
// Extension commands get renamed if they conflict with existing commands
85-
if (cmd.extensionName && commandMap.has(cmd.name)) {
86-
const winner = commandMap.get(cmd.name)!;
87-
let renamedName = `${cmd.extensionName}.${cmd.name}`;
88-
let suffix = 1;
89-
90-
// Keep trying until we find a name that doesn't conflict
91-
while (commandMap.has(renamedName)) {
92-
renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`;
93-
suffix++;
94-
}
95-
96-
finalName = renamedName;
97-
98-
if (!conflictsMap.has(cmd.name)) {
99-
conflictsMap.set(cmd.name, {
100-
name: cmd.name,
101-
winner,
102-
losers: [],
103-
});
104-
}
105-
106-
conflictsMap.get(cmd.name)!.losers.push({
107-
command: cmd,
108-
renamedTo: finalName,
109-
});
110-
}
111-
112-
commandMap.set(finalName, {
113-
...cmd,
114-
name: finalName,
115-
});
116-
}
117-
118-
const conflicts = Array.from(conflictsMap.values());
119-
if (conflicts.length > 0) {
120-
coreEvents.emitSlashCommandConflicts(
121-
conflicts.flatMap((c) =>
122-
c.losers.map((l) => ({
123-
name: c.name,
124-
renamedTo: l.renamedTo,
125-
loserExtensionName: l.command.extensionName,
126-
winnerExtensionName: c.winner.extensionName,
127-
})),
128-
),
129-
);
130-
}
131-
132-
const finalCommands = Object.freeze(Array.from(commandMap.values()));
133-
const finalConflicts = Object.freeze(conflicts);
134-
return new CommandService(finalCommands, finalConflicts);
84+
/**
85+
* Formats and emits telemetry for command conflicts.
86+
*/
87+
private static emitConflictEvents(conflicts: CommandConflict[]): void {
88+
coreEvents.emitSlashCommandConflicts(
89+
conflicts.flatMap((c) =>
90+
c.losers.map((l) => ({
91+
name: c.name,
92+
renamedTo: l.renamedTo,
93+
loserExtensionName: l.command.extensionName,
94+
winnerExtensionName: l.reason.extensionName,
95+
loserMcpServerName: l.command.mcpServerName,
96+
winnerMcpServerName: l.reason.mcpServerName,
97+
loserKind: l.command.kind,
98+
winnerKind: l.reason.kind,
99+
})),
100+
),
101+
);
135102
}
136103

137104
/**

packages/cli/src/services/FileCommandLoader.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js';
3737

3838
interface CommandDirectory {
3939
path: string;
40+
kind: CommandKind;
4041
extensionName?: string;
4142
extensionId?: string;
4243
}
@@ -111,6 +112,7 @@ export class FileCommandLoader implements ICommandLoader {
111112
this.parseAndAdaptFile(
112113
path.join(dirInfo.path, file),
113114
dirInfo.path,
115+
dirInfo.kind,
114116
dirInfo.extensionName,
115117
dirInfo.extensionId,
116118
),
@@ -151,10 +153,16 @@ export class FileCommandLoader implements ICommandLoader {
151153
const storage = this.config?.storage ?? new Storage(this.projectRoot);
152154

153155
// 1. User commands
154-
dirs.push({ path: Storage.getUserCommandsDir() });
156+
dirs.push({
157+
path: Storage.getUserCommandsDir(),
158+
kind: CommandKind.USER_FILE,
159+
});
155160

156-
// 2. Project commands (override user commands)
157-
dirs.push({ path: storage.getProjectCommandsDir() });
161+
// 2. Project commands
162+
dirs.push({
163+
path: storage.getProjectCommandsDir(),
164+
kind: CommandKind.WORKSPACE_FILE,
165+
});
158166

159167
// 3. Extension commands (processed last to detect all conflicts)
160168
if (this.config) {
@@ -165,6 +173,7 @@ export class FileCommandLoader implements ICommandLoader {
165173

166174
const extensionCommandDirs = activeExtensions.map((ext) => ({
167175
path: path.join(ext.path, 'commands'),
176+
kind: CommandKind.EXTENSION_FILE,
168177
extensionName: ext.name,
169178
extensionId: ext.id,
170179
}));
@@ -179,12 +188,14 @@ export class FileCommandLoader implements ICommandLoader {
179188
* Parses a single .toml file and transforms it into a SlashCommand object.
180189
* @param filePath The absolute path to the .toml file.
181190
* @param baseDir The root command directory for name calculation.
191+
* @param kind The CommandKind.
182192
* @param extensionName Optional extension name to prefix commands with.
183193
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
184194
*/
185195
private async parseAndAdaptFile(
186196
filePath: string,
187197
baseDir: string,
198+
kind: CommandKind,
188199
extensionName?: string,
189200
extensionId?: string,
190201
): Promise<SlashCommand | null> {
@@ -286,7 +297,7 @@ export class FileCommandLoader implements ICommandLoader {
286297
return {
287298
name: baseCommandName,
288299
description,
289-
kind: CommandKind.FILE,
300+
kind,
290301
extensionName,
291302
extensionId,
292303
action: async (

packages/cli/src/services/McpPromptLoader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class McpPromptLoader implements ICommandLoader {
4444
name: commandName,
4545
description: prompt.description || `Invoke prompt ${prompt.name}`,
4646
kind: CommandKind.MCP_PROMPT,
47+
mcpServerName: serverName,
4748
autoExecute: !prompt.arguments || prompt.arguments.length === 0,
4849
subCommands: [
4950
{

0 commit comments

Comments
 (0)