summaryrefslogtreecommitdiff
path: root/packages/cli/src/services/FileCommandLoader.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/services/FileCommandLoader.ts')
-rw-r--r--packages/cli/src/services/FileCommandLoader.ts171
1 files changed, 171 insertions, 0 deletions
diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts
new file mode 100644
index 00000000..1b96cb35
--- /dev/null
+++ b/packages/cli/src/services/FileCommandLoader.ts
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { promises as fs } from 'fs';
+import path from 'path';
+import toml from '@iarna/toml';
+import { glob } from 'glob';
+import { z } from 'zod';
+import {
+ Config,
+ getProjectCommandsDir,
+ getUserCommandsDir,
+} from '@google/gemini-cli-core';
+import { ICommandLoader } from './types.js';
+import { CommandKind, SlashCommand } from '../ui/commands/types.js';
+
+/**
+ * Defines the Zod schema for a command definition file. This serves as the
+ * single source of truth for both validation and type inference.
+ */
+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(),
+});
+
+/**
+ * Discovers and loads custom slash commands from .toml files in both the
+ * user's global config directory and the current project's directory.
+ *
+ * This loader is responsible for:
+ * - Recursively scanning command directories.
+ * - Parsing and validating TOML files.
+ * - Adapting valid definitions into executable SlashCommand objects.
+ * - Handling file system errors and malformed files gracefully.
+ */
+export class FileCommandLoader implements ICommandLoader {
+ private readonly projectRoot: string;
+
+ constructor(private readonly config: Config | null) {
+ this.projectRoot = config?.getProjectRoot() || process.cwd();
+ }
+
+ /**
+ * Loads all commands, applying the precedence rule where project-level
+ * commands override user-level commands with the same name.
+ * @param signal An AbortSignal to cancel the loading process.
+ * @returns A promise that resolves to an array of loaded SlashCommands.
+ */
+ async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
+ const commandMap = new Map<string, SlashCommand>();
+ const globOptions = {
+ nodir: true,
+ dot: true,
+ signal,
+ };
+
+ 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);
+ }
+
+ // 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);
+ }
+ } catch (error) {
+ console.error(`[FileCommandLoader] Error during file search:`, error);
+ }
+
+ return Array.from(commandMap.values());
+ }
+
+ /**
+ * 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.
+ * @returns A promise resolving to a SlashCommand, or null if the file is invalid.
+ */
+ private async parseAndAdaptFile(
+ filePath: string,
+ baseDir: string,
+ ): Promise<SlashCommand | null> {
+ let fileContent: string;
+ try {
+ fileContent = await fs.readFile(filePath, 'utf-8');
+ } catch (error: unknown) {
+ console.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) {
+ console.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) {
+ console.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, // length of '.toml'
+ );
+ const commandName = relativePath
+ .split(path.sep)
+ // Sanitize each path segment to prevent ambiguity. Since ':' is our
+ // namespace separator, we replace any literal colons in filenames
+ // with underscores to avoid naming conflicts.
+ .map((segment) => segment.replaceAll(':', '_'))
+ .join(':');
+
+ return {
+ name: commandName,
+ description:
+ validDef.description ||
+ `Custom command from ${path.basename(filePath)}`,
+ kind: CommandKind.FILE,
+ action: async () => ({
+ type: 'submit_prompt',
+ content: validDef.prompt,
+ }),
+ };
+ }
+}