mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
(feat): Initial Version of Custom Commands (#4572)
This commit is contained in:
@@ -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();
|
||||
|
||||
235
packages/cli/src/services/FileCommandLoader.test.ts
Normal file
235
packages/cli/src/services/FileCommandLoader.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
171
packages/cli/src/services/FileCommandLoader.ts
Normal file
171
packages/cli/src/services/FileCommandLoader.ts
Normal file
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user