Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
627 changes: 215 additions & 412 deletions packages/cli/src/services/CommandService.test.ts

Large diffs are not rendered by default.

166 changes: 126 additions & 40 deletions packages/cli/src/services/CommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import type { SlashCommand } from '../ui/commands/types.js';
import { CommandKind } from '../ui/commands/types.js';
import type { ICommandLoader } from './types.js';

export interface CommandConflict {
name: string;
winner: SlashCommand;
losers: Array<{
command: SlashCommand;
renamedTo: string;
reason: SlashCommand;
}>;
}

Expand Down Expand Up @@ -43,20 +44,16 @@ export class CommandService {
* Asynchronously creates and initializes a new CommandService instance.
*
* This factory method orchestrates the entire command loading process. It
* runs all provided loaders in parallel, aggregates their results, handles
* name conflicts for extension commands by renaming them, and then returns a
* fully constructed `CommandService` instance.
* runs all provided loaders in parallel, aggregates their results, and
* resolves name conflicts.
*
* Conflict resolution:
* - Extension commands that conflict with existing commands are renamed to
* `extensionName.commandName`
* - Non-extension commands (built-in, user, project) override earlier commands
* with the same name based on loader order
*
* @param loaders An array of objects that conform to the `ICommandLoader`
* interface. Built-in commands should come first, followed by FileCommandLoader.
* @param signal An AbortSignal to cancel the loading process.
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
* - Built-in and MCP commands always take precedence and are never renamed.
* - Extension, user, and workspace commands that conflict with existing
* commands are renamed to `extensionName.commandName`, `user.commandName` or
* `workspace.commandName`.
* - If multiple file-based commands conflict, all are prefixed and the
* original non-prefixed mapping is removed.
*/
static async create(
loaders: ICommandLoader[],
Expand All @@ -77,36 +74,62 @@ export class CommandService {

const commandMap = new Map<string, SlashCommand>();
const conflictsMap = new Map<string, CommandConflict>();
const firstEncounters = new Map<string, SlashCommand>();

for (const cmd of allCommands) {
let finalName = cmd.name;

// Extension commands get renamed if they conflict with existing commands
if (cmd.extensionName && commandMap.has(cmd.name)) {
const winner = commandMap.get(cmd.name)!;
let renamedName = `${cmd.extensionName}.${cmd.name}`;
let suffix = 1;

// Keep trying until we find a name that doesn't conflict
while (commandMap.has(renamedName)) {
renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`;
suffix++;
const originalName = cmd.name;
let finalName = originalName;

// Handle name conflicts
if (firstEncounters.has(originalName)) {
const first = firstEncounters.get(originalName)!;

// 1. Extension commands get renamed to extension.name if the name was ever claimed
if (cmd.extensionName) {
finalName = this.getRenamedExtensionName(cmd, commandMap);
// In this conflict, the original claimant (first) is the reason the extension command (cmd) is renamed.
this.trackConflict(conflictsMap, originalName, first, cmd, finalName);
}

finalName = renamedName;

if (!conflictsMap.has(cmd.name)) {
conflictsMap.set(cmd.name, {
name: cmd.name,
winner,
losers: [],
});
// 2. User/Workspace commands get prefixed if they conflict
else if (
cmd.kind === CommandKind.USER_FILE ||
cmd.kind === CommandKind.WORKSPACE_FILE
) {
const prefix = this.getKindPrefix(cmd.kind);
finalName = prefix ? `${prefix}.${cmd.name}` : cmd.name;

const existing = commandMap.get(originalName);
// If the existing command is still in the map under the original name,
// and it's a file-based command, rename it too.
if (
existing &&
(existing.kind === CommandKind.USER_FILE ||
existing.kind === CommandKind.WORKSPACE_FILE)
) {
const existingPrefix = this.getKindPrefix(existing.kind);
const renamedExistingName = `${existingPrefix}.${existing.name}`;

commandMap.delete(originalName);
const renamedExisting = { ...existing, name: renamedExistingName };
commandMap.set(renamedExistingName, renamedExisting);

// Report the existing one being renamed because of the current one.
// This ensures the UI correctly identifies the newcomer as the reason for displacement.
this.trackConflict(
conflictsMap,
originalName,
cmd, // current is reason
existing, // existing is renamed
renamedExistingName,
);
}

// Report the current one being renamed because of the original claimant.
this.trackConflict(conflictsMap, originalName, first, cmd, finalName);
}

conflictsMap.get(cmd.name)!.losers.push({
command: cmd,
renamedTo: finalName,
});
} else {
// First time we've seen this command name
firstEncounters.set(originalName, cmd);
}

commandMap.set(finalName, {
Expand All @@ -123,7 +146,9 @@ export class CommandService {
name: c.name,
renamedTo: l.renamedTo,
loserExtensionName: l.command.extensionName,
winnerExtensionName: c.winner.extensionName,
winnerExtensionName: l.reason.extensionName,
loserKind: l.command.kind,
winnerKind: l.reason.kind,
})),
),
);
Expand All @@ -134,6 +159,67 @@ export class CommandService {
return new CommandService(finalCommands, finalConflicts);
}

/**
* Generates a unique name for an extension command to avoid conflicts.
*/
private static getRenamedExtensionName(
cmd: SlashCommand,
commandMap: Map<string, SlashCommand>,
): string {
let renamedName = `${cmd.extensionName}.${cmd.name}`;
let suffix = 1;

// Keep trying until we find a name that doesn't conflict
while (commandMap.has(renamedName)) {
renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`;
suffix++;
}
return renamedName;
}

/**
* Returns the short prefix string for user or workspace commands.
*/
private static getKindPrefix(kind: CommandKind): string | null {
if (kind === CommandKind.USER_FILE) {
return 'user';
}
if (kind === CommandKind.WORKSPACE_FILE) {
return 'workspace';
}
return null;
}

/**
* Records a command conflict in the provided conflicts map.
*
* @param conflictsMap Map to store conflict data.
* @param originalName The base name that had a conflict.
* @param reason The command that caused the rename.
* @param renamedCommand The command that was renamed.
* @param renamedTo The new name assigned to the command.
*/
private static trackConflict(
conflictsMap: Map<string, CommandConflict>,
originalName: string,
reason: SlashCommand,
renamedCommand: SlashCommand,
renamedTo: string,
) {
if (!conflictsMap.has(originalName)) {
conflictsMap.set(originalName, {
name: originalName,
losers: [],
});
}

conflictsMap.get(originalName)!.losers.push({
command: renamedCommand,
renamedTo,
reason,
});
}

/**
* Retrieves the currently loaded and de-duplicated list of slash commands.
*
Expand Down
19 changes: 15 additions & 4 deletions packages/cli/src/services/FileCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js';

interface CommandDirectory {
path: string;
kind: CommandKind;
extensionName?: string;
extensionId?: string;
}
Expand Down Expand Up @@ -111,6 +112,7 @@ export class FileCommandLoader implements ICommandLoader {
this.parseAndAdaptFile(
path.join(dirInfo.path, file),
dirInfo.path,
dirInfo.kind,
dirInfo.extensionName,
dirInfo.extensionId,
),
Expand Down Expand Up @@ -151,10 +153,16 @@ export class FileCommandLoader implements ICommandLoader {
const storage = this.config?.storage ?? new Storage(this.projectRoot);

// 1. User commands
dirs.push({ path: Storage.getUserCommandsDir() });
dirs.push({
path: Storage.getUserCommandsDir(),
kind: CommandKind.USER_FILE,
});

// 2. Project commands (override user commands)
dirs.push({ path: storage.getProjectCommandsDir() });
// 2. Project commands
dirs.push({
path: storage.getProjectCommandsDir(),
kind: CommandKind.WORKSPACE_FILE,
});

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

const extensionCommandDirs = activeExtensions.map((ext) => ({
path: path.join(ext.path, 'commands'),
kind: CommandKind.EXTENSION_FILE,
extensionName: ext.name,
extensionId: ext.id,
}));
Expand All @@ -179,12 +188,14 @@ export class FileCommandLoader implements ICommandLoader {
* Parses a single .toml file and transforms it into a SlashCommand object.
* @param filePath The absolute path to the .toml file.
* @param baseDir The root command directory for name calculation.
* @param kind The CommandKind.
* @param extensionName Optional extension name to prefix commands with.
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
*/
private async parseAndAdaptFile(
filePath: string,
baseDir: string,
kind: CommandKind,
extensionName?: string,
extensionId?: string,
): Promise<SlashCommand | null> {
Expand Down Expand Up @@ -286,7 +297,7 @@ export class FileCommandLoader implements ICommandLoader {
return {
name: baseCommandName,
description,
kind: CommandKind.FILE,
kind,
extensionName,
extensionId,
action: async (
Expand Down
Loading
Loading