Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
484 changes: 61 additions & 423 deletions packages/cli/src/services/CommandService.test.ts

Large diffs are not rendered by default.

139 changes: 53 additions & 86 deletions packages/cli/src/services/CommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,8 @@

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

export interface CommandConflict {
name: string;
winner: SlashCommand;
losers: Array<{
command: SlashCommand;
renamedTo: string;
}>;
}
import type { ICommandLoader, CommandConflict } from './types.js';
import { SlashCommandResolver } from './SlashCommandResolver.js';

/**
* Orchestrates the discovery and loading of all slash commands for the CLI.
Expand All @@ -24,9 +16,9 @@ export interface CommandConflict {
* with an array of `ICommandLoader` instances, each responsible for fetching
* commands from a specific source (e.g., built-in code, local files).
*
* The CommandService is responsible for invoking these loaders, aggregating their
* results, and resolving any name conflicts. This architecture allows the command
* system to be extended with new sources without modifying the service itself.
* It uses a delegating resolver to reconcile name conflicts, ensuring that
* all commands are uniquely addressable via source-specific prefixes while
* allowing built-in commands to retain their primary names.
*/
export class CommandService {
/**
Expand All @@ -42,96 +34,71 @@ 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.
* This factory method orchestrates the loading process and delegates
* conflict resolution to the SlashCommandResolver.
*
* 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.
* @param loaders An array of loaders to fetch commands from.
* @param signal An AbortSignal to allow cancellation.
* @returns A promise that resolves to a fully initialized CommandService.
*/
static async create(
loaders: ICommandLoader[],
signal: AbortSignal,
): Promise<CommandService> {
const allCommands = await this.loadAllCommands(loaders, signal);
const { finalCommands, conflicts } =
SlashCommandResolver.resolve(allCommands);

if (conflicts.length > 0) {
this.emitConflictEvents(conflicts);
}

return new CommandService(
Object.freeze(finalCommands),
Object.freeze(conflicts),
);
}

/**
* Invokes all loaders in parallel and flattens the results.
*/
private static async loadAllCommands(
loaders: ICommandLoader[],
signal: AbortSignal,
): Promise<SlashCommand[]> {
const results = await Promise.allSettled(
loaders.map((loader) => loader.loadCommands(signal)),
);

const allCommands: SlashCommand[] = [];
const commands: SlashCommand[] = [];
for (const result of results) {
if (result.status === 'fulfilled') {
allCommands.push(...result.value);
commands.push(...result.value);
} else {
debugLogger.debug('A command loader failed:', result.reason);
}
}
return commands;
}

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

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++;
}

finalName = renamedName;

if (!conflictsMap.has(cmd.name)) {
conflictsMap.set(cmd.name, {
name: cmd.name,
winner,
losers: [],
});
}

conflictsMap.get(cmd.name)!.losers.push({
command: cmd,
renamedTo: finalName,
});
}

commandMap.set(finalName, {
...cmd,
name: finalName,
});
}

const conflicts = Array.from(conflictsMap.values());
if (conflicts.length > 0) {
coreEvents.emitSlashCommandConflicts(
conflicts.flatMap((c) =>
c.losers.map((l) => ({
name: c.name,
renamedTo: l.renamedTo,
loserExtensionName: l.command.extensionName,
winnerExtensionName: c.winner.extensionName,
})),
),
);
}

const finalCommands = Object.freeze(Array.from(commandMap.values()));
const finalConflicts = Object.freeze(conflicts);
return new CommandService(finalCommands, finalConflicts);
/**
* Formats and emits telemetry for command conflicts.
*/
private static emitConflictEvents(conflicts: CommandConflict[]): void {
coreEvents.emitSlashCommandConflicts(
conflicts.flatMap((c) =>
c.losers.map((l) => ({
name: c.name,
renamedTo: l.renamedTo,
loserExtensionName: l.command.extensionName,
winnerExtensionName: l.reason.extensionName,
loserMcpServerName: l.command.mcpServerName,
winnerMcpServerName: l.reason.mcpServerName,
loserKind: l.command.kind,
winnerKind: l.reason.kind,
})),
),
);
}

/**
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
1 change: 1 addition & 0 deletions packages/cli/src/services/McpPromptLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class McpPromptLoader implements ICommandLoader {
name: commandName,
description: prompt.description || `Invoke prompt ${prompt.name}`,
kind: CommandKind.MCP_PROMPT,
mcpServerName: serverName,
autoExecute: !prompt.arguments || prompt.arguments.length === 0,
subCommands: [
{
Expand Down
Loading
Loading