mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
feat(commands): add custom commands support for extensions (#4703)
This commit is contained in:
@@ -35,6 +35,11 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
);
|
||||
return {
|
||||
...actualServer,
|
||||
IdeClient: vi.fn().mockImplementation(() => ({
|
||||
getConnectionStatus: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
})),
|
||||
loadEnvironment: vi.fn(),
|
||||
loadServerHierarchicalMemory: vi.fn(
|
||||
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
|
||||
|
||||
@@ -42,6 +42,31 @@ describe('loadExtensions', () => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should include extension path in loaded extension', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].path).toBe(extensionDir);
|
||||
expect(extensions[0].config.name).toBe('test-extension');
|
||||
});
|
||||
|
||||
it('should load context file path when GEMINI.md is present', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
|
||||
@@ -13,6 +13,7 @@ export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions');
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
|
||||
|
||||
export interface Extension {
|
||||
path: string;
|
||||
config: ExtensionConfig;
|
||||
contextFiles: string[];
|
||||
}
|
||||
@@ -90,6 +91,7 @@ function loadExtension(extensionDir: string): Extension | null {
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
return {
|
||||
path: extensionDir,
|
||||
config,
|
||||
contextFiles,
|
||||
};
|
||||
@@ -121,6 +123,7 @@ export function annotateActiveExtensions(
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: true,
|
||||
path: extension.path,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -136,6 +139,7 @@ export function annotateActiveExtensions(
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: false,
|
||||
path: extension.path,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -153,6 +157,7 @@ export function annotateActiveExtensions(
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive,
|
||||
path: extension.path,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -177,4 +177,176 @@ describe('CommandService', () => {
|
||||
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
|
||||
});
|
||||
|
||||
it('should rename extension commands when they conflict', async () => {
|
||||
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
|
||||
const userCommand = createMockCommand('sync', CommandKind.FILE);
|
||||
const extensionCommand1 = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'firebase',
|
||||
description: '[firebase] Deploy to Firebase',
|
||||
};
|
||||
const extensionCommand2 = {
|
||||
...createMockCommand('sync', CommandKind.FILE),
|
||||
extensionName: 'git-helper',
|
||||
description: '[git-helper] Sync with remote',
|
||||
};
|
||||
|
||||
const mockLoader1 = new MockCommandLoader([builtinCommand]);
|
||||
const mockLoader2 = new MockCommandLoader([
|
||||
userCommand,
|
||||
extensionCommand1,
|
||||
extensionCommand2,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader1, mockLoader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(4);
|
||||
|
||||
// Built-in command keeps original name
|
||||
const deployBuiltin = commands.find(
|
||||
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
|
||||
);
|
||||
expect(deployBuiltin).toBeDefined();
|
||||
expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
// Extension command conflicting with built-in gets renamed
|
||||
const deployExtension = commands.find(
|
||||
(cmd) => cmd.name === 'firebase.deploy',
|
||||
);
|
||||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.extensionName).toBe('firebase');
|
||||
|
||||
// User command keeps original name
|
||||
const syncUser = commands.find(
|
||||
(cmd) => cmd.name === 'sync' && !cmd.extensionName,
|
||||
);
|
||||
expect(syncUser).toBeDefined();
|
||||
expect(syncUser?.kind).toBe(CommandKind.FILE);
|
||||
|
||||
// Extension command conflicting with user command gets renamed
|
||||
const syncExtension = commands.find(
|
||||
(cmd) => cmd.name === 'git-helper.sync',
|
||||
);
|
||||
expect(syncExtension).toBeDefined();
|
||||
expect(syncExtension?.extensionName).toBe('git-helper');
|
||||
});
|
||||
|
||||
it('should handle user/project command override correctly', async () => {
|
||||
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
|
||||
const userCommand = createMockCommand('help', CommandKind.FILE);
|
||||
const projectCommand = createMockCommand('deploy', CommandKind.FILE);
|
||||
const userDeployCommand = createMockCommand('deploy', CommandKind.FILE);
|
||||
|
||||
const mockLoader1 = new MockCommandLoader([builtinCommand]);
|
||||
const mockLoader2 = new MockCommandLoader([
|
||||
userCommand,
|
||||
userDeployCommand,
|
||||
projectCommand,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader1, mockLoader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(2);
|
||||
|
||||
// User command overrides built-in
|
||||
const helpCommand = commands.find((cmd) => cmd.name === 'help');
|
||||
expect(helpCommand).toBeDefined();
|
||||
expect(helpCommand?.kind).toBe(CommandKind.FILE);
|
||||
|
||||
// Project command overrides user command (last wins)
|
||||
const deployCommand = commands.find((cmd) => cmd.name === 'deploy');
|
||||
expect(deployCommand).toBeDefined();
|
||||
expect(deployCommand?.kind).toBe(CommandKind.FILE);
|
||||
});
|
||||
|
||||
it('should handle secondary conflicts when renaming extension commands', async () => {
|
||||
// User has both /deploy and /gcp.deploy commands
|
||||
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
|
||||
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
|
||||
|
||||
// Extension also has a deploy command that will conflict with user's /deploy
|
||||
const extensionCommand = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'gcp',
|
||||
description: '[gcp] Deploy to Google Cloud',
|
||||
};
|
||||
|
||||
const mockLoader = new MockCommandLoader([
|
||||
userCommand1,
|
||||
userCommand2,
|
||||
extensionCommand,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(3);
|
||||
|
||||
// Original user command keeps its name
|
||||
const deployUser = commands.find(
|
||||
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
|
||||
);
|
||||
expect(deployUser).toBeDefined();
|
||||
|
||||
// User's dot notation command keeps its name
|
||||
const gcpDeployUser = commands.find(
|
||||
(cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName,
|
||||
);
|
||||
expect(gcpDeployUser).toBeDefined();
|
||||
|
||||
// Extension command gets renamed with suffix due to secondary conflict
|
||||
const deployExtension = commands.find(
|
||||
(cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp',
|
||||
);
|
||||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
|
||||
});
|
||||
|
||||
it('should handle multiple secondary conflicts with incrementing suffixes', async () => {
|
||||
// User has /deploy, /gcp.deploy, and /gcp.deploy1
|
||||
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
|
||||
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
|
||||
const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE);
|
||||
|
||||
// Extension has a deploy command
|
||||
const extensionCommand = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'gcp',
|
||||
description: '[gcp] Deploy to Google Cloud',
|
||||
};
|
||||
|
||||
const mockLoader = new MockCommandLoader([
|
||||
userCommand1,
|
||||
userCommand2,
|
||||
userCommand3,
|
||||
extensionCommand,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(4);
|
||||
|
||||
// Extension command gets renamed with suffix 2 due to multiple conflicts
|
||||
const deployExtension = commands.find(
|
||||
(cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp',
|
||||
);
|
||||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,13 +30,17 @@ export class CommandService {
|
||||
*
|
||||
* This factory method orchestrates the entire command loading process. It
|
||||
* runs all provided loaders in parallel, aggregates their results, handles
|
||||
* name conflicts by letting the last-loaded command win, and then returns a
|
||||
* name conflicts for extension commands by renaming them, and then returns a
|
||||
* fully constructed `CommandService` instance.
|
||||
*
|
||||
* Conflict resolution:
|
||||
* - Extension commands that conflict with existing commands are renamed to
|
||||
* `extensionName.commandName`
|
||||
* - Non-extension commands (built-in, user, project) override earlier commands
|
||||
* with the same name based on loader order
|
||||
*
|
||||
* @param loaders An array of objects that conform to the `ICommandLoader`
|
||||
* interface. The order of loaders is significant: if multiple loaders
|
||||
* provide a command with the same name, the command from the loader that
|
||||
* appears later in the array will take precedence.
|
||||
* interface. Built-in commands should come first, followed by FileCommandLoader.
|
||||
* @param signal An AbortSignal to cancel the loading process.
|
||||
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
|
||||
*/
|
||||
@@ -57,12 +61,28 @@ export class CommandService {
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate commands using a Map. The last one found with a given name wins.
|
||||
// This creates a natural override system based on the order of the loaders
|
||||
// passed to the constructor.
|
||||
const commandMap = new Map<string, SlashCommand>();
|
||||
for (const cmd of allCommands) {
|
||||
commandMap.set(cmd.name, cmd);
|
||||
let finalName = cmd.name;
|
||||
|
||||
// Extension commands get renamed if they conflict with existing commands
|
||||
if (cmd.extensionName && commandMap.has(cmd.name)) {
|
||||
let renamedName = `${cmd.extensionName}.${cmd.name}`;
|
||||
let suffix = 1;
|
||||
|
||||
// Keep trying until we find a name that doesn't conflict
|
||||
while (commandMap.has(renamedName)) {
|
||||
renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
finalName = renamedName;
|
||||
}
|
||||
|
||||
commandMap.set(finalName, {
|
||||
...cmd,
|
||||
name: finalName,
|
||||
});
|
||||
}
|
||||
|
||||
const finalCommands = Object.freeze(Array.from(commandMap.values()));
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { FileCommandLoader } from './FileCommandLoader.js';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
Config,
|
||||
getProjectCommandsDir,
|
||||
getUserCommandsDir,
|
||||
} from '@google/gemini-cli-core';
|
||||
import mock from 'mock-fs';
|
||||
import { FileCommandLoader } from './FileCommandLoader.js';
|
||||
import { assert, vi } from 'vitest';
|
||||
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
||||
import {
|
||||
@@ -85,7 +86,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
@@ -176,7 +177,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
@@ -194,9 +195,11 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const loader = new FileCommandLoader({
|
||||
getProjectRoot: () => '/path/to/project',
|
||||
} as Config);
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => '/path/to/project'),
|
||||
getExtensions: vi.fn(() => []),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]!.name).toBe('gcp:pipelines:run');
|
||||
@@ -212,7 +215,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
@@ -221,7 +224,7 @@ describe('FileCommandLoader', () => {
|
||||
expect(command.name).toBe('git:commit');
|
||||
});
|
||||
|
||||
it('overrides user commands with project commands', async () => {
|
||||
it('returns both user and project commands in order', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
||||
mock({
|
||||
@@ -233,16 +236,15 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader({
|
||||
getProjectRoot: () => process.cwd(),
|
||||
} as Config);
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => []),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command.action?.(
|
||||
expect(commands).toHaveLength(2);
|
||||
const userResult = await commands[0].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/test',
|
||||
@@ -252,10 +254,25 @@ describe('FileCommandLoader', () => {
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('Project prompt');
|
||||
if (userResult?.type === 'submit_prompt') {
|
||||
expect(userResult.content).toBe('User prompt');
|
||||
} else {
|
||||
assert.fail('Incorrect action type');
|
||||
assert.fail('Incorrect action type for user command');
|
||||
}
|
||||
const projectResult = await commands[1].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/test',
|
||||
name: 'test',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (projectResult?.type === 'submit_prompt') {
|
||||
expect(projectResult.content).toBe('Project prompt');
|
||||
} else {
|
||||
assert.fail('Incorrect action type for project command');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -268,7 +285,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
@@ -284,7 +301,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
@@ -299,7 +316,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
@@ -308,7 +325,7 @@ describe('FileCommandLoader', () => {
|
||||
|
||||
it('handles file system errors gracefully', async () => {
|
||||
mock({}); // Mock an empty file system
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
@@ -321,7 +338,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
@@ -336,7 +353,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
@@ -351,7 +368,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
@@ -362,6 +379,298 @@ describe('FileCommandLoader', () => {
|
||||
expect(command.name).toBe('legacy_command');
|
||||
});
|
||||
|
||||
describe('Extension Command Loading', () => {
|
||||
it('loads commands from active extensions', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/test-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'user.toml': 'prompt = "User command"',
|
||||
},
|
||||
[projectCommandsDir]: {
|
||||
'project.toml': 'prompt = "Project command"',
|
||||
},
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'ext.toml': 'prompt = "Extension command"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(3);
|
||||
const commandNames = commands.map((cmd) => cmd.name);
|
||||
expect(commandNames).toEqual(['user', 'project', 'ext']);
|
||||
|
||||
const extCommand = commands.find((cmd) => cmd.name === 'ext');
|
||||
expect(extCommand?.extensionName).toBe('test-ext');
|
||||
expect(extCommand?.description).toMatch(/^\[test-ext\]/);
|
||||
});
|
||||
|
||||
it('extension commands have extensionName metadata for conflict resolution', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/test-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'deploy.toml': 'prompt = "Extension deploy command"',
|
||||
},
|
||||
},
|
||||
[userCommandsDir]: {
|
||||
'deploy.toml': 'prompt = "User deploy command"',
|
||||
},
|
||||
[projectCommandsDir]: {
|
||||
'deploy.toml': 'prompt = "Project deploy command"',
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
// Return all commands, even duplicates
|
||||
expect(commands).toHaveLength(3);
|
||||
|
||||
expect(commands[0].name).toBe('deploy');
|
||||
expect(commands[0].extensionName).toBeUndefined();
|
||||
const result0 = await commands[0].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/deploy',
|
||||
name: 'deploy',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
expect(result0?.type).toBe('submit_prompt');
|
||||
if (result0?.type === 'submit_prompt') {
|
||||
expect(result0.content).toBe('User deploy command');
|
||||
}
|
||||
|
||||
expect(commands[1].name).toBe('deploy');
|
||||
expect(commands[1].extensionName).toBeUndefined();
|
||||
const result1 = await commands[1].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/deploy',
|
||||
name: 'deploy',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
expect(result1?.type).toBe('submit_prompt');
|
||||
if (result1?.type === 'submit_prompt') {
|
||||
expect(result1.content).toBe('Project deploy command');
|
||||
}
|
||||
|
||||
expect(commands[2].name).toBe('deploy');
|
||||
expect(commands[2].extensionName).toBe('test-ext');
|
||||
expect(commands[2].description).toMatch(/^\[test-ext\]/);
|
||||
const result2 = await commands[2].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/deploy',
|
||||
name: 'deploy',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
expect(result2?.type).toBe('submit_prompt');
|
||||
if (result2?.type === 'submit_prompt') {
|
||||
expect(result2.content).toBe('Extension deploy command');
|
||||
}
|
||||
});
|
||||
|
||||
it('only loads commands from active extensions', async () => {
|
||||
const extensionDir1 = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/active-ext',
|
||||
);
|
||||
const extensionDir2 = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/inactive-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir1]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'active-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'active.toml': 'prompt = "Active extension command"',
|
||||
},
|
||||
},
|
||||
[extensionDir2]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'inactive-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'inactive.toml': 'prompt = "Inactive extension command"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'active-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir1,
|
||||
},
|
||||
{
|
||||
name: 'inactive-ext',
|
||||
version: '1.0.0',
|
||||
isActive: false,
|
||||
path: extensionDir2,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('active');
|
||||
expect(commands[0].extensionName).toBe('active-ext');
|
||||
expect(commands[0].description).toMatch(/^\[active-ext\]/);
|
||||
});
|
||||
|
||||
it('handles missing extension commands directory gracefully', async () => {
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/no-commands',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'no-commands',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
// No commands directory
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'no-commands',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles nested command structure in extensions', async () => {
|
||||
const extensionDir = path.join(process.cwd(), '.gemini/extensions/a');
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'a',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
b: {
|
||||
'c.toml': 'prompt = "Nested command from extension a"',
|
||||
d: {
|
||||
'e.toml': 'prompt = "Deeply nested command"',
|
||||
},
|
||||
},
|
||||
'simple.toml': 'prompt = "Simple command"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{ name: 'a', version: '1.0.0', isActive: true, path: extensionDir },
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(3);
|
||||
|
||||
const commandNames = commands.map((cmd) => cmd.name).sort();
|
||||
expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']);
|
||||
|
||||
const nestedCmd = commands.find((cmd) => cmd.name === 'b:c');
|
||||
expect(nestedCmd?.extensionName).toBe('a');
|
||||
expect(nestedCmd?.description).toMatch(/^\[a\]/);
|
||||
expect(nestedCmd).toBeDefined();
|
||||
const result = await nestedCmd!.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/b:c',
|
||||
name: 'b:c',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('Nested command from extension a');
|
||||
} else {
|
||||
assert.fail('Incorrect action type');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shorthand Argument Processor Integration', () => {
|
||||
it('correctly processes a command with {{args}}', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
ShellProcessor,
|
||||
} from './prompt-processors/shellProcessor.js';
|
||||
|
||||
interface CommandDirectory {
|
||||
path: string;
|
||||
extensionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the Zod schema for a command definition file. This serves as the
|
||||
* single source of truth for both validation and type inference.
|
||||
@@ -65,13 +70,18 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all commands, applying the precedence rule where project-level
|
||||
* commands override user-level commands with the same name.
|
||||
* Loads all commands from user, project, and extension directories.
|
||||
* Returns commands in order: user → project → extensions (alphabetically).
|
||||
*
|
||||
* Order is important for conflict resolution in CommandService:
|
||||
* - User/project commands (without extensionName) use "last wins" strategy
|
||||
* - Extension commands (with extensionName) get renamed if conflicts exist
|
||||
*
|
||||
* @param signal An AbortSignal to cancel the loading process.
|
||||
* @returns A promise that resolves to an array of loaded SlashCommands.
|
||||
* @returns A promise that resolves to an array of all loaded SlashCommands.
|
||||
*/
|
||||
async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const commandMap = new Map<string, SlashCommand>();
|
||||
const allCommands: SlashCommand[] = [];
|
||||
const globOptions = {
|
||||
nodir: true,
|
||||
dot: true,
|
||||
@@ -79,54 +89,85 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
follow: true,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
// Load commands from each directory
|
||||
const commandDirs = this.getCommandDirectories();
|
||||
for (const dirInfo of commandDirs) {
|
||||
try {
|
||||
const files = await glob('**/*.toml', {
|
||||
...globOptions,
|
||||
cwd: dirInfo.path,
|
||||
});
|
||||
|
||||
// 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);
|
||||
const commandPromises = files.map((file) =>
|
||||
this.parseAndAdaptFile(
|
||||
path.join(dirInfo.path, file),
|
||||
dirInfo.path,
|
||||
dirInfo.extensionName,
|
||||
),
|
||||
);
|
||||
|
||||
const commands = (await Promise.all(commandPromises)).filter(
|
||||
(cmd): cmd is SlashCommand => cmd !== null,
|
||||
);
|
||||
|
||||
// Add all commands without deduplication
|
||||
allCommands.push(...commands);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error(
|
||||
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[FileCommandLoader] Error during file search:`, error);
|
||||
}
|
||||
|
||||
return Array.from(commandMap.values());
|
||||
return allCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all command directories in order for loading.
|
||||
* User commands → Project commands → Extension commands
|
||||
* This order ensures extension commands can detect all conflicts.
|
||||
*/
|
||||
private getCommandDirectories(): CommandDirectory[] {
|
||||
const dirs: CommandDirectory[] = [];
|
||||
|
||||
// 1. User commands
|
||||
dirs.push({ path: getUserCommandsDir() });
|
||||
|
||||
// 2. Project commands (override user commands)
|
||||
dirs.push({ path: getProjectCommandsDir(this.projectRoot) });
|
||||
|
||||
// 3. Extension commands (processed last to detect all conflicts)
|
||||
if (this.config) {
|
||||
const activeExtensions = this.config
|
||||
.getExtensions()
|
||||
.filter((ext) => ext.isActive)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading
|
||||
|
||||
const extensionCommandDirs = activeExtensions.map((ext) => ({
|
||||
path: path.join(ext.path, 'commands'),
|
||||
extensionName: ext.name,
|
||||
}));
|
||||
|
||||
dirs.push(...extensionCommandDirs);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param extensionName Optional extension name to prefix commands with.
|
||||
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
|
||||
*/
|
||||
private async parseAndAdaptFile(
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
extensionName?: string,
|
||||
): Promise<SlashCommand | null> {
|
||||
let fileContent: string;
|
||||
try {
|
||||
@@ -167,7 +208,7 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
0,
|
||||
relativePathWithExt.length - 5, // length of '.toml'
|
||||
);
|
||||
const commandName = relativePath
|
||||
const baseCommandName = relativePath
|
||||
.split(path.sep)
|
||||
// Sanitize each path segment to prevent ambiguity. Since ':' is our
|
||||
// namespace separator, we replace any literal colons in filenames
|
||||
@@ -175,11 +216,18 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
// Add extension name tag for extension commands
|
||||
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
|
||||
let description = validDef.description || defaultDescription;
|
||||
if (extensionName) {
|
||||
description = `[${extensionName}] ${description}`;
|
||||
}
|
||||
|
||||
const processors: IPromptProcessor[] = [];
|
||||
|
||||
// Add the Shell Processor if needed.
|
||||
if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) {
|
||||
processors.push(new ShellProcessor(commandName));
|
||||
processors.push(new ShellProcessor(baseCommandName));
|
||||
}
|
||||
|
||||
// The presence of '{{args}}' is the switch that determines the behavior.
|
||||
@@ -190,18 +238,17 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
}
|
||||
|
||||
return {
|
||||
name: commandName,
|
||||
description:
|
||||
validDef.description ||
|
||||
`Custom command from ${path.basename(filePath)}`,
|
||||
name: baseCommandName,
|
||||
description,
|
||||
kind: CommandKind.FILE,
|
||||
extensionName,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!context.invocation) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Critical error: Command '${commandName}' was executed without invocation context.`,
|
||||
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
|
||||
);
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
|
||||
@@ -157,6 +157,9 @@ export interface SlashCommand {
|
||||
|
||||
kind: CommandKind;
|
||||
|
||||
// Optional metadata for extension commands
|
||||
extensionName?: string;
|
||||
|
||||
// The action to run. Optional for parent commands that only group sub-commands.
|
||||
action?: (
|
||||
context: CommandContext,
|
||||
|
||||
@@ -74,11 +74,12 @@ describe('useSlashCommandProcessor', () => {
|
||||
const mockSetQuittingMessages = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: () => '/mock/cwd',
|
||||
getSessionId: () => 'test-session',
|
||||
getGeminiClient: () => ({
|
||||
getProjectRoot: vi.fn(() => '/mock/cwd'),
|
||||
getSessionId: vi.fn(() => 'test-session'),
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
setHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
})),
|
||||
getExtensions: vi.fn(() => []),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockSettings = {} as LoadedSettings;
|
||||
|
||||
Reference in New Issue
Block a user