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,
    });
  }
}