diff options
| author | Daniel Lee <[email protected]> | 2025-07-28 18:40:47 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-29 01:40:47 +0000 |
| commit | 7356764a489b47bc43dae9e9653380cbe9bce294 (patch) | |
| tree | ded97cfbfecf19c68f5b495950906f64088dafad /packages/cli/src/services/FileCommandLoader.ts | |
| parent | 871e0dfab811192f67cd80bc270580ad784ffdc8 (diff) | |
feat(commands): add custom commands support for extensions (#4703)
Diffstat (limited to 'packages/cli/src/services/FileCommandLoader.ts')
| -rw-r--r-- | packages/cli/src/services/FileCommandLoader.ts | 135 |
1 files changed, 91 insertions, 44 deletions
diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index c96acead..5b8d8c42 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -35,6 +35,11 @@ import { ShellProcessor, } from './prompt-processors/shellProcessor.js'; +interface CommandDirectory { + path: string; + extensionName?: string; +} + /** * Defines the Zod schema for a command definition file. This serves as the * single source of truth for both validation and type inference. @@ -65,13 +70,18 @@ export class FileCommandLoader implements ICommandLoader { } /** - * Loads all commands, applying the precedence rule where project-level - * commands override user-level commands with the same name. + * Loads all commands from user, project, and extension directories. + * Returns commands in order: user → project → extensions (alphabetically). + * + * Order is important for conflict resolution in CommandService: + * - User/project commands (without extensionName) use "last wins" strategy + * - Extension commands (with extensionName) get renamed if conflicts exist + * * @param signal An AbortSignal to cancel the loading process. - * @returns A promise that resolves to an array of loaded SlashCommands. + * @returns A promise that resolves to an array of all loaded SlashCommands. */ async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> { - const commandMap = new Map<string, SlashCommand>(); + const allCommands: SlashCommand[] = []; const globOptions = { nodir: true, dot: true, @@ -79,54 +89,85 @@ export class FileCommandLoader implements ICommandLoader { follow: true, }; - try { - // User Commands - const userDir = getUserCommandsDir(); - const userFiles = await glob('**/*.toml', { - ...globOptions, - cwd: userDir, - }); - const userCommandPromises = userFiles.map((file) => - this.parseAndAdaptFile(path.join(userDir, file), userDir), - ); - const userCommands = (await Promise.all(userCommandPromises)).filter( - (cmd): cmd is SlashCommand => cmd !== null, - ); - for (const cmd of userCommands) { - commandMap.set(cmd.name, cmd); - } + // Load commands from each directory + const commandDirs = this.getCommandDirectories(); + for (const dirInfo of commandDirs) { + try { + const files = await glob('**/*.toml', { + ...globOptions, + cwd: dirInfo.path, + }); - // Project Commands (these intentionally override user commands) - const projectDir = getProjectCommandsDir(this.projectRoot); - const projectFiles = await glob('**/*.toml', { - ...globOptions, - cwd: projectDir, - }); - const projectCommandPromises = projectFiles.map((file) => - this.parseAndAdaptFile(path.join(projectDir, file), projectDir), - ); - const projectCommands = ( - await Promise.all(projectCommandPromises) - ).filter((cmd): cmd is SlashCommand => cmd !== null); - for (const cmd of projectCommands) { - commandMap.set(cmd.name, cmd); + const commandPromises = files.map((file) => + this.parseAndAdaptFile( + path.join(dirInfo.path, file), + dirInfo.path, + dirInfo.extensionName, + ), + ); + + const commands = (await Promise.all(commandPromises)).filter( + (cmd): cmd is SlashCommand => cmd !== null, + ); + + // Add all commands without deduplication + allCommands.push(...commands); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error( + `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, + error, + ); + } } - } catch (error) { - console.error(`[FileCommandLoader] Error during file search:`, error); } - return Array.from(commandMap.values()); + return allCommands; + } + + /** + * Get all command directories in order for loading. + * User commands → Project commands → Extension commands + * This order ensures extension commands can detect all conflicts. + */ + private getCommandDirectories(): CommandDirectory[] { + const dirs: CommandDirectory[] = []; + + // 1. User commands + dirs.push({ path: getUserCommandsDir() }); + + // 2. Project commands (override user commands) + dirs.push({ path: getProjectCommandsDir(this.projectRoot) }); + + // 3. Extension commands (processed last to detect all conflicts) + if (this.config) { + const activeExtensions = this.config + .getExtensions() + .filter((ext) => ext.isActive) + .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading + + const extensionCommandDirs = activeExtensions.map((ext) => ({ + path: path.join(ext.path, 'commands'), + extensionName: ext.name, + })); + + dirs.push(...extensionCommandDirs); + } + + return dirs; } /** * 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 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, + extensionName?: string, ): Promise<SlashCommand | null> { let fileContent: string; try { @@ -167,7 +208,7 @@ export class FileCommandLoader implements ICommandLoader { 0, relativePathWithExt.length - 5, // length of '.toml' ); - const commandName = relativePath + const baseCommandName = relativePath .split(path.sep) // Sanitize each path segment to prevent ambiguity. Since ':' is our // namespace separator, we replace any literal colons in filenames @@ -175,11 +216,18 @@ export class FileCommandLoader implements ICommandLoader { .map((segment) => segment.replaceAll(':', '_')) .join(':'); + // Add extension name tag for extension commands + const defaultDescription = `Custom command from ${path.basename(filePath)}`; + let description = validDef.description || defaultDescription; + if (extensionName) { + description = `[${extensionName}] ${description}`; + } + const processors: IPromptProcessor[] = []; // Add the Shell Processor if needed. if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) { - processors.push(new ShellProcessor(commandName)); + processors.push(new ShellProcessor(baseCommandName)); } // The presence of '{{args}}' is the switch that determines the behavior. @@ -190,18 +238,17 @@ export class FileCommandLoader implements ICommandLoader { } return { - name: commandName, - description: - validDef.description || - `Custom command from ${path.basename(filePath)}`, + name: baseCommandName, + description, kind: CommandKind.FILE, + extensionName, action: async ( context: CommandContext, _args: string, ): Promise<SlashCommandActionReturn> => { if (!context.invocation) { console.error( - `[FileCommandLoader] Critical error: Command '${commandName}' was executed without invocation context.`, + `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, ); return { type: 'submit_prompt', |
