summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/config.test.ts5
-rw-r--r--packages/cli/src/config/extension.test.ts25
-rw-r--r--packages/cli/src/config/extension.ts5
-rw-r--r--packages/cli/src/services/CommandService.test.ts172
-rw-r--r--packages/cli/src/services/CommandService.ts36
-rw-r--r--packages/cli/src/services/FileCommandLoader.test.ts361
-rw-r--r--packages/cli/src/services/FileCommandLoader.ts135
-rw-r--r--packages/cli/src/ui/commands/types.ts3
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts9
9 files changed, 669 insertions, 82 deletions
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index 55780320..7f47660d 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -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) =>
diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts
index 6b2a3f83..85852bd7 100644
--- a/packages/cli/src/config/extension.test.ts
+++ b/packages/cli/src/config/extension.test.ts
@@ -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,
diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts
index adefec29..1922f55a 100644
--- a/packages/cli/src/config/extension.ts
+++ b/packages/cli/src/config/extension.ts
@@ -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,
});
}
diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts
index 28731f81..e2d5b9f5 100644
--- a/packages/cli/src/services/CommandService.test.ts
+++ b/packages/cli/src/services/CommandService.test.ts
@@ -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');
+ });
});
diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts
index ef4f4d14..78e4817b 100644
--- a/packages/cli/src/services/CommandService.ts
+++ b/packages/cli/src/services/CommandService.ts
@@ -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()));
diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts
index e3cbceb2..f4f8ac2c 100644
--- a/packages/cli/src/services/FileCommandLoader.test.ts
+++ b/packages/cli/src/services/FileCommandLoader.test.ts
@@ -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();
diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts
index c96acead..5b8d8c42 100644
--- a/packages/cli/src/services/FileCommandLoader.ts
+++ b/packages/cli/src/services/FileCommandLoader.ts
@@ -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',
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
index 2844177f..900be866 100644
--- a/packages/cli/src/ui/commands/types.ts
+++ b/packages/cli/src/ui/commands/types.ts
@@ -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,
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 5b367cd4..42c2e277 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -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;