basant307/AI_Governance_Project
045
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 