summaryrefslogtreecommitdiff
path: root/packages/cli/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/services')
-rw-r--r--packages/cli/src/services/BuiltinCommandLoader.test.ts8
-rw-r--r--packages/cli/src/services/FileCommandLoader.test.ts235
-rw-r--r--packages/cli/src/services/FileCommandLoader.ts171
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,
+ }),
+ };
+ }
+}