feat(commands): add custom commands support for extensions (#4703)

This commit is contained in:
Daniel Lee 2025-07-28 18:40:47 -07:00 committed by GitHub
parent 871e0dfab8
commit 7356764a48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 705 additions and 83 deletions

View File

@ -33,10 +33,44 @@ The `gemini-extension.json` file contains the configuration for the extension. T
}
```
- `name`: The name of the extension. This is used to uniquely identify the extension. This should match the name of your extension directory.
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
- `version`: The version of the extension.
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
## Extension Commands
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
### Example
An extension named `gcp` with the following structure:
```
.gemini/extensions/gcp/
├── gemini-extension.json
└── commands/
├── deploy.toml
└── gcs/
└── sync.toml
```
Would provide these commands:
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
### Conflict Resolution
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
For example, if both a user and the `gcp` extension define a `deploy` command:
- `/deploy` - Executes the user's deploy command
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)

View File

@ -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) =>

View File

@ -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,

View File

@ -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,
});
}

View File

@ -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');
});
});

View File

@ -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()));

View File

@ -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();

View File

@ -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',

View File

@ -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,

View File

@ -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;

View File

@ -81,6 +81,7 @@ export interface GeminiCLIExtension {
name: string;
version: string;
isActive: boolean;
path: string;
}
export interface FileFilteringOptions {
respectGitIgnore: boolean;