diff options
Diffstat (limited to 'packages/cli/src/services')
| -rw-r--r-- | packages/cli/src/services/BuiltinCommandLoader.test.ts | 8 | ||||
| -rw-r--r-- | packages/cli/src/services/FileCommandLoader.test.ts | 235 | ||||
| -rw-r--r-- | packages/cli/src/services/FileCommandLoader.ts | 171 |
3 files changed, 410 insertions, 4 deletions
diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 642309dc..0e64b1ac 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -72,7 +72,7 @@ describe('BuiltinCommandLoader', () => { it('should correctly pass the config object to command factory functions', async () => { const loader = new BuiltinCommandLoader(mockConfig); - await loader.loadCommands(); + await loader.loadCommands(new AbortController().signal); expect(ideCommandMock).toHaveBeenCalledTimes(1); expect(ideCommandMock).toHaveBeenCalledWith(mockConfig); @@ -84,7 +84,7 @@ describe('BuiltinCommandLoader', () => { // Override the mock's behavior for this specific test. ideCommandMock.mockReturnValue(null); const loader = new BuiltinCommandLoader(mockConfig); - const commands = await loader.loadCommands(); + const commands = await loader.loadCommands(new AbortController().signal); // The 'ide' command should be filtered out. const ideCmd = commands.find((c) => c.name === 'ide'); @@ -97,7 +97,7 @@ describe('BuiltinCommandLoader', () => { it('should handle a null config gracefully when calling factories', async () => { const loader = new BuiltinCommandLoader(null); - await loader.loadCommands(); + await loader.loadCommands(new AbortController().signal); expect(ideCommandMock).toHaveBeenCalledTimes(1); expect(ideCommandMock).toHaveBeenCalledWith(null); expect(restoreCommandMock).toHaveBeenCalledTimes(1); @@ -106,7 +106,7 @@ describe('BuiltinCommandLoader', () => { it('should return a list of all loaded commands', async () => { const loader = new BuiltinCommandLoader(mockConfig); - const commands = await loader.loadCommands(); + const commands = await loader.loadCommands(new AbortController().signal); const aboutCmd = commands.find((c) => c.name === 'about'); expect(aboutCmd).toBeDefined(); diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts new file mode 100644 index 00000000..518c9230 --- /dev/null +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FileCommandLoader } from './FileCommandLoader.js'; +import { + Config, + getProjectCommandsDir, + getUserCommandsDir, +} from '@google/gemini-cli-core'; +import mock from 'mock-fs'; +import { assert } from 'vitest'; +import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; + +const mockContext = createMockCommandContext(); + +describe('FileCommandLoader', () => { + const signal: AbortSignal = new AbortController().signal; + + afterEach(() => { + mock.restore(); + }); + + it('loads a single command from a file', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "This is a test prompt"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('test'); + + const result = await command.action?.(mockContext, ''); + if (result?.type === 'submit_prompt') { + expect(result.content).toBe('This is a test prompt'); + } else { + assert.fail('Incorrect action type'); + } + }); + + it('loads multiple commands', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test1.toml': 'prompt = "Prompt 1"', + 'test2.toml': 'prompt = "Prompt 2"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(2); + }); + + it('creates deeply nested namespaces correctly', async () => { + const userCommandsDir = getUserCommandsDir(); + + mock({ + [userCommandsDir]: { + gcp: { + pipelines: { + 'run.toml': 'prompt = "run pipeline"', + }, + }, + }, + }); + const loader = new FileCommandLoader({ + getProjectRoot: () => '/path/to/project', + } as Config); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + expect(commands[0]!.name).toBe('gcp:pipelines:run'); + }); + + it('creates namespaces from nested directories', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + git: { + 'commit.toml': 'prompt = "git commit prompt"', + }, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('git:commit'); + }); + + it('overrides user commands with project commands', async () => { + const userCommandsDir = getUserCommandsDir(); + const projectCommandsDir = getProjectCommandsDir(process.cwd()); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "User prompt"', + }, + [projectCommandsDir]: { + 'test.toml': 'prompt = "Project prompt"', + }, + }); + + const loader = new FileCommandLoader({ + getProjectRoot: () => process.cwd(), + } as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + + const result = await command.action?.(mockContext, ''); + if (result?.type === 'submit_prompt') { + expect(result.content).toBe('Project prompt'); + } else { + assert.fail('Incorrect action type'); + } + }); + + it('ignores files with TOML syntax errors', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'invalid.toml': 'this is not valid toml', + 'good.toml': 'prompt = "This one is fine"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('good'); + }); + + it('ignores files that are semantically invalid (missing prompt)', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'no_prompt.toml': 'description = "This file is missing a prompt"', + 'good.toml': 'prompt = "This one is fine"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('good'); + }); + + it('handles filename edge cases correctly', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.v1.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('test.v1'); + }); + + it('handles file system errors gracefully', async () => { + mock({}); // Mock an empty file system + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(0); + }); + + it('uses a default description if not provided', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.description).toBe('Custom command from test.toml'); + }); + + it('uses the provided description', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.description).toBe('My test command'); + }); + + it('should sanitize colons in filenames to prevent namespace conflicts', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'legacy:command.toml': 'prompt = "This is a legacy command"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + + // Verify that the ':' in the filename was replaced with an '_' + expect(command.name).toBe('legacy_command'); + }); +}); 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, + }), + }; + } +} |
