feat(commands): add custom commands support for extensions (#4703)

This commit is contained in:
Daniel Lee
2025-07-28 18:40:47 -07:00
committed by GitHub
parent 871e0dfab8
commit 7356764a48
11 changed files with 705 additions and 83 deletions

View File

@@ -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',