summaryrefslogtreecommitdiff
path: root/packages/cli/src/services/FileCommandLoader.ts
diff options
context:
space:
mode:
authorDaniel Lee <[email protected]>2025-07-28 18:40:47 -0700
committerGitHub <[email protected]>2025-07-29 01:40:47 +0000
commit7356764a489b47bc43dae9e9653380cbe9bce294 (patch)
treeded97cfbfecf19c68f5b495950906f64088dafad /packages/cli/src/services/FileCommandLoader.ts
parent871e0dfab811192f67cd80bc270580ad784ffdc8 (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.ts135
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',