File size: 6,601 Bytes
84aa3bf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | /**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import type { CommandConflict } from './types.js';
/**
* Internal registry to track commands and conflicts during resolution.
*/
class CommandRegistry {
readonly commandMap = new Map<string, SlashCommand>();
readonly conflictsMap = new Map<string, CommandConflict>();
readonly firstEncounters = new Map<string, SlashCommand>();
get finalCommands(): SlashCommand[] {
return Array.from(this.commandMap.values());
}
get conflicts(): CommandConflict[] {
return Array.from(this.conflictsMap.values());
}
}
/**
* Resolves name conflicts among slash commands.
*
* Rules:
* 1. Built-in commands always keep the original name.
* 2. All other types are prefixed with their source name (e.g. user.name).
* 3. If multiple non-built-in commands conflict, all of them are renamed.
*/
export class SlashCommandResolver {
/**
* Orchestrates conflict resolution by applying renaming rules to ensures
* every command has a unique name.
*/
static resolve(allCommands: SlashCommand[]): {
finalCommands: SlashCommand[];
conflicts: CommandConflict[];
} {
const registry = new CommandRegistry();
for (const cmd of allCommands) {
const originalName = cmd.name;
let finalName = originalName;
const shouldAlwaysPrefix =
cmd.kind === CommandKind.SKILL && !!cmd.extensionName;
if (shouldAlwaysPrefix) {
finalName = this.getRenamedName(
originalName,
this.getPrefix(cmd),
registry.commandMap,
cmd.kind,
);
} else if (registry.firstEncounters.has(originalName)) {
// We've already seen a command with this name, so resolve the conflict.
finalName = this.handleConflict(cmd, registry);
} else {
// Track the first claimant to report them as the conflict reason later.
registry.firstEncounters.set(originalName, cmd);
}
// Store under final name, ensuring the command object reflects it.
registry.commandMap.set(finalName, {
...cmd,
name: finalName,
});
}
return {
finalCommands: registry.finalCommands,
conflicts: registry.conflicts,
};
}
/**
* Resolves a name collision by deciding which command keeps the name and which is renamed.
*
* @param incoming The command currently being processed that has a name collision.
* @param registry The internal state of the resolution process.
* @returns The final name to be assigned to the `incoming` command.
*/
private static handleConflict(
incoming: SlashCommand,
registry: CommandRegistry,
): string {
const collidingName = incoming.name;
const originalClaimant = registry.firstEncounters.get(collidingName)!;
// Incoming built-in takes priority. Prefix any existing owner.
if (incoming.kind === CommandKind.BUILT_IN) {
this.prefixExistingCommand(collidingName, incoming, registry);
return collidingName;
}
// Incoming non-built-in is renamed to its source-prefixed version.
const renamedName = this.getRenamedName(
incoming.name,
this.getPrefix(incoming),
registry.commandMap,
incoming.kind,
);
this.trackConflict(
registry.conflictsMap,
collidingName,
originalClaimant,
incoming,
renamedName,
);
// Prefix current owner as well if it isn't a built-in.
this.prefixExistingCommand(collidingName, incoming, registry);
return renamedName;
}
/**
* Safely renames the command currently occupying a name in the registry.
*
* @param name The name of the command to prefix.
* @param reason The incoming command that is causing the prefixing.
* @param registry The internal state of the resolution process.
*/
private static prefixExistingCommand(
name: string,
reason: SlashCommand,
registry: CommandRegistry,
): void {
const currentOwner = registry.commandMap.get(name);
// Only non-built-in commands can be prefixed.
if (!currentOwner || currentOwner.kind === CommandKind.BUILT_IN) {
return;
}
// Determine the new name for the owner using its source prefix.
const renamedName = this.getRenamedName(
currentOwner.name,
this.getPrefix(currentOwner),
registry.commandMap,
currentOwner.kind,
);
// Update the registry: remove the old name and add the owner under the new name.
registry.commandMap.delete(name);
const renamedOwner = { ...currentOwner, name: renamedName };
registry.commandMap.set(renamedName, renamedOwner);
// Record the conflict so the user can be notified of the prefixing.
this.trackConflict(
registry.conflictsMap,
name,
reason,
currentOwner,
renamedName,
);
}
/**
* Generates a unique name using numeric suffixes if needed.
*/
private static getRenamedName(
name: string,
prefix: string | undefined,
commandMap: Map<string, SlashCommand>,
kind?: CommandKind,
): string {
const isExtensionPrefix =
kind === CommandKind.SKILL || kind === CommandKind.EXTENSION_FILE;
const separator = isExtensionPrefix ? ':' : '.';
const base = prefix ? `${prefix}${separator}${name}` : name;
let renamedName = base;
let suffix = 1;
while (commandMap.has(renamedName)) {
renamedName = `${base}${suffix}`;
suffix++;
}
return renamedName;
}
/**
* Returns a suitable prefix for a conflicting command.
*/
private static getPrefix(cmd: SlashCommand): string | undefined {
switch (cmd.kind) {
case CommandKind.EXTENSION_FILE:
case CommandKind.SKILL:
return cmd.extensionName;
case CommandKind.MCP_PROMPT:
return cmd.mcpServerName;
case CommandKind.USER_FILE:
return 'user';
case CommandKind.WORKSPACE_FILE:
return 'workspace';
default:
return undefined;
}
}
/**
* Logs a conflict event.
*/
private static trackConflict(
conflictsMap: Map<string, CommandConflict>,
originalName: string,
reason: SlashCommand,
displacedCommand: SlashCommand,
renamedTo: string,
) {
if (!conflictsMap.has(originalName)) {
conflictsMap.set(originalName, {
name: originalName,
losers: [],
});
}
conflictsMap.get(originalName)!.losers.push({
command: displacedCommand,
renamedTo,
reason,
});
}
}
|