diff options
| author | Daniel Lee <[email protected]> | 2025-07-28 18:40:47 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-29 01:40:47 +0000 |
| commit | 7356764a489b47bc43dae9e9653380cbe9bce294 (patch) | |
| tree | ded97cfbfecf19c68f5b495950906f64088dafad /packages/cli/src | |
| parent | 871e0dfab811192f67cd80bc270580ad784ffdc8 (diff) | |
feat(commands): add custom commands support for extensions (#4703)
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/config/config.test.ts | 5 | ||||
| -rw-r--r-- | packages/cli/src/config/extension.test.ts | 25 | ||||
| -rw-r--r-- | packages/cli/src/config/extension.ts | 5 | ||||
| -rw-r--r-- | packages/cli/src/services/CommandService.test.ts | 172 | ||||
| -rw-r--r-- | packages/cli/src/services/CommandService.ts | 36 | ||||
| -rw-r--r-- | packages/cli/src/services/FileCommandLoader.test.ts | 361 | ||||
| -rw-r--r-- | packages/cli/src/services/FileCommandLoader.ts | 135 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/types.ts | 3 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.test.ts | 9 |
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; |
