| |
| |
| |
| |
| |
|
|
| import { promises as fs } from 'node:fs'; |
| import path from 'node:path'; |
| import toml from '@iarna/toml'; |
| import { glob } from 'glob'; |
| import { z } from 'zod'; |
| import { Storage, coreEvents, type Config } from '@google/gemini-cli-core'; |
| import type { ICommandLoader } from './types.js'; |
| import type { |
| CommandContext, |
| SlashCommand, |
| SlashCommandActionReturn, |
| } from '../ui/commands/types.js'; |
| import { CommandKind } from '../ui/commands/types.js'; |
| import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; |
| import type { |
| IPromptProcessor, |
| PromptPipelineContent, |
| } from './prompt-processors/types.js'; |
| import { |
| SHORTHAND_ARGS_PLACEHOLDER, |
| SHELL_INJECTION_TRIGGER, |
| AT_FILE_INJECTION_TRIGGER, |
| } from './prompt-processors/types.js'; |
| import { |
| ConfirmationRequiredError, |
| ShellProcessor, |
| } from './prompt-processors/shellProcessor.js'; |
| import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; |
| import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; |
|
|
| export interface CommandDirectory { |
| path: string; |
| kind: CommandKind; |
| extensionName?: string; |
| extensionId?: string; |
| } |
|
|
| export interface CommandFileGroup { |
| displayName: string; |
| path: string; |
| files: string[]; |
| error?: string; |
| } |
|
|
| |
| |
| |
| |
| const TomlCommandDefSchema = z.object({ |
| prompt: z.string({ |
| required_error: "The 'prompt' field is required.", |
| invalid_type_error: "The 'prompt' field must be a string.", |
| }), |
| description: z.string().optional(), |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class FileCommandLoader implements ICommandLoader { |
| private readonly projectRoot: string; |
| private readonly folderTrustEnabled: boolean; |
| private readonly isTrustedFolder: boolean; |
|
|
| constructor(private readonly config: Config | null) { |
| this.folderTrustEnabled = !!config?.getFolderTrust(); |
| this.isTrustedFolder = !!config?.isTrustedFolder(); |
| this.projectRoot = config?.getProjectRoot() || process.cwd(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> { |
| const allCommands: SlashCommand[] = []; |
| const globOptions = { |
| nodir: true, |
| dot: true, |
| signal, |
| follow: true, |
| }; |
|
|
| |
| const commandDirs = this.getCommandDirectories(); |
| for (const dirInfo of commandDirs) { |
| try { |
| const files = await glob('**/*.toml', { |
| ...globOptions, |
| cwd: dirInfo.path, |
| }); |
|
|
| const commandPromises = files.map((file) => |
| this.parseAndAdaptFile( |
| path.join(dirInfo.path, file), |
| dirInfo.path, |
| dirInfo.kind, |
| dirInfo.extensionName, |
| dirInfo.extensionId, |
| ), |
| ); |
|
|
| const commands = (await Promise.all(commandPromises)).filter( |
| (cmd): cmd is SlashCommand => cmd !== null, |
| ); |
|
|
| |
| allCommands.push(...commands); |
| } catch (error) { |
| if ( |
| !signal.aborted && |
| |
| (error as { code?: string })?.code !== 'ENOENT' |
| ) { |
| coreEvents.emitFeedback( |
| 'error', |
| `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, |
| error, |
| ); |
| } |
| } |
| } |
|
|
| return allCommands; |
| } |
|
|
| |
| |
| |
| async listAvailableFiles(): Promise<CommandFileGroup[]> { |
| const directories = this.getCommandDirectories(); |
| const groups: CommandFileGroup[] = []; |
|
|
| for (const dir of directories) { |
| const displayName = this.getDisplayName(dir); |
|
|
| try { |
| const files = await glob('**/*.toml', { cwd: dir.path }); |
| if (files.length > 0) { |
| groups.push({ |
| displayName, |
| path: dir.path, |
| files: [...files].sort(), |
| }); |
| } |
| } catch (e) { |
| |
| if ((e as { code?: string }).code === 'ENOENT') { |
| continue; |
| } |
|
|
| groups.push({ |
| displayName, |
| path: dir.path, |
| files: [], |
| error: e instanceof Error ? e.message : String(e), |
| }); |
| } |
| } |
|
|
| return groups; |
| } |
|
|
| |
| |
| |
| private getDisplayName(dir: CommandDirectory): string { |
| switch (dir.kind) { |
| case CommandKind.USER_FILE: |
| return 'User'; |
| case CommandKind.WORKSPACE_FILE: |
| return 'Project'; |
| case CommandKind.EXTENSION_FILE: |
| return `Extension: ${dir.extensionName || 'Unknown'}`; |
| default: |
| return 'Custom'; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| private getCommandDirectories(): CommandDirectory[] { |
| const dirs: CommandDirectory[] = []; |
|
|
| const storage = this.config?.storage ?? new Storage(this.projectRoot); |
|
|
| |
| const userCommandsDir = Storage.getUserCommandsDir(); |
| dirs.push({ |
| path: userCommandsDir, |
| kind: CommandKind.USER_FILE, |
| }); |
|
|
| |
| |
| if ( |
| !storage.isWorkspaceHomeDir() && |
| (!this.folderTrustEnabled || this.isTrustedFolder) |
| ) { |
| dirs.push({ |
| path: storage.getProjectCommandsDir(), |
| kind: CommandKind.WORKSPACE_FILE, |
| }); |
| } |
|
|
| |
| if (this.config && (!this.folderTrustEnabled || this.isTrustedFolder)) { |
| const activeExtensions = this.config |
| .getExtensions() |
| .filter((ext) => ext.isActive) |
| .sort((a, b) => a.name.localeCompare(b.name)); |
|
|
| const extensionCommandDirs = activeExtensions.map((ext) => ({ |
| path: path.join(ext.path, 'commands'), |
| kind: CommandKind.EXTENSION_FILE, |
| extensionName: ext.name, |
| extensionId: ext.id, |
| })); |
|
|
| dirs.push(...extensionCommandDirs); |
| } |
|
|
| return dirs; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private async parseAndAdaptFile( |
| filePath: string, |
| baseDir: string, |
| kind: CommandKind, |
| extensionName?: string, |
| extensionId?: string, |
| ): Promise<SlashCommand | null> { |
| let fileContent: string; |
| try { |
| fileContent = await fs.readFile(filePath, 'utf-8'); |
| } catch (error: unknown) { |
| coreEvents.emitFeedback( |
| 'error', |
| `[FileCommandLoader] Failed to read file ${filePath}:`, |
| error instanceof Error ? error.message : String(error), |
| ); |
| return null; |
| } |
|
|
| let parsed: unknown; |
| try { |
| parsed = toml.parse(fileContent); |
| } catch (error: unknown) { |
| coreEvents.emitFeedback( |
| 'error', |
| `[FileCommandLoader] Failed to parse TOML file ${filePath}:`, |
| error instanceof Error ? error.message : String(error), |
| ); |
| return null; |
| } |
|
|
| const validationResult = TomlCommandDefSchema.safeParse(parsed); |
|
|
| if (!validationResult.success) { |
| coreEvents.emitFeedback( |
| 'error', |
| `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, |
| validationResult.error.flatten(), |
| ); |
| return null; |
| } |
|
|
| const validDef = validationResult.data; |
|
|
| const relativePathWithExt = path.relative(baseDir, filePath); |
| const relativePath = relativePathWithExt.substring( |
| 0, |
| relativePathWithExt.length - 5, |
| ); |
| const baseCommandName = relativePath |
| .split(path.sep) |
| |
| |
| .map((segment) => { |
| let sanitized = segment.replace(/[^a-zA-Z0-9_\-.]/g, '_'); |
|
|
| |
| if (sanitized.length > 50) { |
| sanitized = sanitized.substring(0, 47) + '...'; |
| } |
| return sanitized; |
| }) |
| .join(':'); |
|
|
| |
| const defaultDescription = `Custom command from ${path.basename(filePath)}`; |
| let description = validDef.description || defaultDescription; |
|
|
| description = sanitizeForDisplay(description, 100); |
|
|
| if (extensionName) { |
| description = `[${extensionName}] ${description}`; |
| } |
|
|
| const processors: IPromptProcessor[] = []; |
| const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); |
| const usesShellInjection = validDef.prompt.includes( |
| SHELL_INJECTION_TRIGGER, |
| ); |
| const usesAtFileInjection = validDef.prompt.includes( |
| AT_FILE_INJECTION_TRIGGER, |
| ); |
|
|
| |
| |
| |
| if (usesAtFileInjection) { |
| processors.push(new AtFileProcessor(baseCommandName)); |
| } |
|
|
| |
| |
| if (usesShellInjection || usesArgs) { |
| processors.push(new ShellProcessor(baseCommandName)); |
| } |
|
|
| |
| |
| if (!usesArgs) { |
| processors.push(new DefaultArgumentProcessor()); |
| } |
|
|
| return { |
| name: baseCommandName, |
| description, |
| kind, |
| extensionName, |
| extensionId, |
| action: async ( |
| context: CommandContext, |
| _args: string, |
| ): Promise<SlashCommandActionReturn> => { |
| if (!context.invocation) { |
| coreEvents.emitFeedback( |
| 'error', |
| `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, |
| ); |
| return { |
| type: 'submit_prompt', |
| content: [{ text: validDef.prompt }], |
| }; |
| } |
|
|
| try { |
| let processedContent: PromptPipelineContent = [ |
| { text: validDef.prompt }, |
| ]; |
| for (const processor of processors) { |
| processedContent = await processor.process( |
| processedContent, |
| context, |
| ); |
| } |
|
|
| return { |
| type: 'submit_prompt', |
| content: processedContent, |
| }; |
| } catch (e) { |
| |
| if (e instanceof ConfirmationRequiredError) { |
| |
| return { |
| type: 'confirm_shell_commands', |
| commandsToConfirm: e.commandsToConfirm, |
| originalInvocation: { |
| raw: context.invocation.raw, |
| }, |
| }; |
| } |
| |
| throw e; |
| } |
| }, |
| }; |
| } |
| } |
|
|