CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
FileCommandLoader.ts373 linesDownload Raw Back to services
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { promises as fs } from 'node:fs';8import * as fsSync from 'node:fs';9import path from 'node:path';10import toml from '@iarna/toml';11import { glob } from 'glob';12import { z } from 'zod';13import type { Config } from '@qwen-code/qwen-code-core';14import {15  createDebugLogger,16  EXTENSIONS_CONFIG_FILENAME,17  Storage,18} from '@qwen-code/qwen-code-core';19import type { ICommandLoader } from './types.js';20import {21  parseMarkdownCommand,22  MarkdownCommandDefSchema,23} from './markdown-command-parser.js';24import {25  createSlashCommandFromDefinition,26  type CommandDefinition,27} from './command-factory.js';28import type { SlashCommand } from '../ui/commands/types.js';29 30interface CommandDirectory {31  path: string;32  extensionName?: string;33}34 35const debugLogger = createDebugLogger('FILE_COMMAND_LOADER');36 37/**38 * Defines the Zod schema for a command definition file. This serves as the39 * single source of truth for both validation and type inference.40 */41const TomlCommandDefSchema = z.object({42  prompt: z.string({43    required_error: "The 'prompt' field is required.",44    invalid_type_error: "The 'prompt' field must be a string.",45  }),46  description: z.string().optional(),47});48 49/**50 * Discovers and loads custom slash commands from .toml files in both the51 * user's global config directory and the current project's directory.52 *53 * This loader is responsible for:54 * - Recursively scanning command directories.55 * - Parsing and validating TOML files.56 * - Adapting valid definitions into executable SlashCommand objects.57 * - Handling file system errors and malformed files gracefully.58 */59export class FileCommandLoader implements ICommandLoader {60  private readonly projectRoot: string;61  private readonly folderTrustEnabled: boolean;62  private readonly folderTrust: boolean;63 64  constructor(private readonly config: Config | null) {65    this.folderTrustEnabled = !!config?.getFolderTrustFeature();66    this.folderTrust = !!config?.getFolderTrust();67    this.projectRoot = config?.getProjectRoot() || process.cwd();68  }69 70  /**71   * Loads all commands from user, project, and extension directories.72   * Returns commands in order: user → project → extensions (alphabetically).73   *74   * Order is important for conflict resolution in CommandService:75   * - User/project commands (without extensionName) use "last wins" strategy76   * - Extension commands (with extensionName) get renamed if conflicts exist77   *78   * @param signal An AbortSignal to cancel the loading process.79   * @returns A promise that resolves to an array of all loaded SlashCommands.80   */81  async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {82    if (this.config?.getBareMode?.()) {83      debugLogger.debug('Bare mode enabled, skipping auto-discovered commands');84      return [];85    }86 87    const allCommands: SlashCommand[] = [];88    const globOptions = {89      nodir: true,90      dot: true,91      signal,92      follow: true,93    };94 95    // Load commands from each directory96    const commandDirs = this.getCommandDirectories();97    for (const dirInfo of commandDirs) {98      try {99        // Scan both .toml and .md files100        const tomlFiles = await glob('**/*.toml', {101          ...globOptions,102          cwd: dirInfo.path,103        });104        const mdFiles = await glob('**/*.md', {105          ...globOptions,106          cwd: dirInfo.path,107        });108 109        if (this.folderTrustEnabled && !this.folderTrust) {110          return [];111        }112 113        // Process TOML files114        const tomlCommandPromises = tomlFiles.map((file) =>115          this.parseAndAdaptTomlFile(116            path.join(dirInfo.path, file),117            dirInfo.path,118            dirInfo.extensionName,119          ),120        );121 122        // Process Markdown files123        const mdCommandPromises = mdFiles.map((file) =>124          this.parseAndAdaptMarkdownFile(125            path.join(dirInfo.path, file),126            dirInfo.path,127            dirInfo.extensionName,128          ),129        );130 131        const commands = (132          await Promise.all([...tomlCommandPromises, ...mdCommandPromises])133        ).filter((cmd): cmd is SlashCommand => cmd !== null);134 135        // Add all commands without deduplication136        allCommands.push(...commands);137      } catch (error) {138        // Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)139        const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';140        const isAbortError =141          error instanceof Error && error.name === 'AbortError';142        if (!isEnoent && !isAbortError) {143          debugLogger.error(144            `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,145            error,146          );147        }148      }149    }150 151    return allCommands;152  }153 154  /**155   * Get all command directories in order for loading.156   * User commands → Project commands → Extension commands157   * This order ensures extension commands can detect all conflicts.158   */159  private getCommandDirectories(): CommandDirectory[] {160    const dirs: CommandDirectory[] = [];161 162    const storage = this.config?.storage ?? new Storage(this.projectRoot);163 164    // 1. User commands165    dirs.push({ path: Storage.getUserCommandsDir() });166 167    // 2. Project commands (override user commands)168    dirs.push({ path: storage.getProjectCommandsDir() });169 170    // 3. Extension commands (processed last to detect all conflicts)171    if (this.config) {172      const activeExtensions = this.config173        .getExtensions()174        .filter((ext) => ext.isActive)175        .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading176 177      // Collect command directories from each extension178      for (const ext of activeExtensions) {179        // Get commands paths from extension config180        const commandsPaths = this.getExtensionCommandsPaths(ext);181 182        for (const cmdPath of commandsPaths) {183          dirs.push({184            path: cmdPath,185            extensionName: ext.displayName ?? ext.name,186          });187        }188      }189    }190 191    return dirs;192  }193 194  /**195   * Get commands paths from an extension.196   * Returns paths from config.commands if specified, otherwise defaults to 'commands' directory.197   */198  private getExtensionCommandsPaths(ext: {199    path: string;200    name: string;201  }): string[] {202    // Try to get extension config203    try {204      const configPath = path.join(ext.path, EXTENSIONS_CONFIG_FILENAME);205      if (fsSync.existsSync(configPath)) {206        const configContent = fsSync.readFileSync(configPath, 'utf-8');207        const config = JSON.parse(configContent);208 209        if (config.commands) {210          const commandsArray = Array.isArray(config.commands)211            ? config.commands212            : [config.commands];213 214          return commandsArray215            .map((cmdPath: string) =>216              path.isAbsolute(cmdPath) ? cmdPath : path.join(ext.path, cmdPath),217            )218            .filter((cmdPath: string) => {219              try {220                return fsSync.existsSync(cmdPath);221              } catch {222                return false;223              }224            });225        }226      }227    } catch (error) {228      debugLogger.warn(229        `Failed to read extension config for ${ext.name}:`,230        error,231      );232    }233 234    // Default fallback: use 'commands' directory235    const defaultPath = path.join(ext.path, 'commands');236    try {237      if (fsSync.existsSync(defaultPath)) {238        return [defaultPath];239      }240    } catch {241      // Ignore242    }243 244    return [];245  }246 247  /**248   * Parses a single .toml file and transforms it into a SlashCommand object.249   * @param filePath The absolute path to the .toml file.250   * @param baseDir The root command directory for name calculation.251   * @param extensionName Optional extension name to prefix commands with.252   * @returns A promise resolving to a SlashCommand, or null if the file is invalid.253   */254  private async parseAndAdaptTomlFile(255    filePath: string,256    baseDir: string,257    extensionName?: string,258  ): Promise<SlashCommand | null> {259    let fileContent: string;260    try {261      fileContent = await fs.readFile(filePath, 'utf-8');262    } catch (error: unknown) {263      debugLogger.error(264        `[FileCommandLoader] Failed to read file ${filePath}:`,265        error instanceof Error ? error.message : String(error),266      );267      return null;268    }269 270    let parsed: unknown;271    try {272      parsed = toml.parse(fileContent);273    } catch (error: unknown) {274      debugLogger.error(275        `[FileCommandLoader] Failed to parse TOML file ${filePath}:`,276        error instanceof Error ? error.message : String(error),277      );278      return null;279    }280 281    const validationResult = TomlCommandDefSchema.safeParse(parsed);282 283    if (!validationResult.success) {284      debugLogger.error(285        `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`,286        validationResult.error.flatten(),287      );288      return null;289    }290 291    const validDef = validationResult.data;292 293    // Use factory to create command294    return createSlashCommandFromDefinition(295      filePath,296      baseDir,297      validDef,298      extensionName,299      '.toml',300    );301  }302 303  /**304   * Parses a single .md file and transforms it into a SlashCommand object.305   * @param filePath The absolute path to the .md file.306   * @param baseDir The root command directory for name calculation.307   * @param extensionName Optional extension name to prefix commands with.308   * @returns A promise resolving to a SlashCommand, or null if the file is invalid.309   */310  private async parseAndAdaptMarkdownFile(311    filePath: string,312    baseDir: string,313    extensionName?: string,314  ): Promise<SlashCommand | null> {315    let fileContent: string;316    try {317      fileContent = await fs.readFile(filePath, 'utf-8');318    } catch (error: unknown) {319      debugLogger.error(320        `[FileCommandLoader] Failed to read file ${filePath}:`,321        error instanceof Error ? error.message : String(error),322      );323      return null;324    }325 326    let parsed: ReturnType<typeof parseMarkdownCommand>;327    try {328      parsed = parseMarkdownCommand(fileContent);329    } catch (error: unknown) {330      debugLogger.error(331        `[FileCommandLoader] Failed to parse Markdown file ${filePath}:`,332        error instanceof Error ? error.message : String(error),333      );334      return null;335    }336 337    const validationResult = MarkdownCommandDefSchema.safeParse(parsed);338 339    if (!validationResult.success) {340      debugLogger.error(341        `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`,342        validationResult.error.flatten(),343      );344      return null;345    }346 347    const validDef = validationResult.data;348 349    // Convert to CommandDefinition format350    const definition: CommandDefinition = {351      prompt: validDef.prompt,352      description:353        validDef.frontmatter?.description &&354        typeof validDef.frontmatter.description === 'string'355          ? validDef.frontmatter.description356          : undefined,357      whenToUse: validDef.frontmatter?.when_to_use,358      argumentHint: validDef.frontmatter?.['argument-hint'],359      disableModelInvocation:360        validDef.frontmatter?.['disable-model-invocation'],361    };362 363    // Use factory to create command364    return createSlashCommandFromDefinition(365      filePath,366      baseDir,367      definition,368      extensionName,369      '.md',370    );371  }372}373 
basant307/AI_Governance_Project · CoolFace