From 84616626f5be2350bc19558094fb30262977fb56 Mon Sep 17 00:00:00 2001 From: JunYoung Ka <82663161+Jwhyee@users.noreply.github.com> Date: Fri, 1 May 2026 04:54:17 +0900 Subject: [PATCH] feat(cli): Add 'list' subcommand to '/commands' (#22324) Co-authored-by: Coco Sheng Co-authored-by: Spencer --- docs/cli/cli-reference.md | 1 + docs/cli/custom-commands.md | 1 + docs/reference/commands.md | 5 + .../cli/src/services/FileCommandLoader.ts | 62 ++++++++++- .../commandsCommand.test.ts.snap | 12 ++ .../src/ui/commands/commandsCommand.test.ts | 105 +++++++++++++++++- .../cli/src/ui/commands/commandsCommand.ts | 71 +++++++++++- 7 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 41cc766175..259edcec1f 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -33,6 +33,7 @@ These commands are available within the interactive REPL. | -------------------- | ----------------------------------------------- | | `/skills reload` | Reload discovered skills from disk | | `/agents reload` | Reload the agent registry | +| `/commands list` | List available custom slash commands | | `/commands reload` | Reload custom slash commands | | `/memory reload` | Reload context files (for example, `GEMINI.md`) | | `/mcp reload` | Restart and reload MCP servers | diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index 3cb3cea36a..3b431fca66 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -34,6 +34,7 @@ separator (`/` or `\`) being converted to a colon (`:`). > [!TIP] > After creating or modifying `.toml` command files, run > `/commands reload` to pick up your changes without restarting the CLI. +> To see all available command files, run `/commands list`. ## TOML file format (v1) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 22605d9b08..5bc336dd0c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -111,6 +111,11 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Manage custom slash commands loaded from `.toml` files. - **Sub-commands:** + - **`list`**: + - **Description:** List available custom command `.toml` files from all + sources (user-level `~/.gemini/commands/`, project-level + `/.gemini/commands/`, and active extensions). + - **Usage:** `/commands list` - **`reload`**: - **Description:** Reload custom command definitions from all sources (user-level `~/.gemini/commands/`, project-level diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 7321837c93..1ad03fb4bb 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -34,13 +34,20 @@ import { import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; -interface CommandDirectory { +export interface CommandDirectory { path: string; kind: CommandKind; extensionName?: string; extensionId?: string; } +export interface CommandFileGroup { + displayName: string; + path: string; + files: string[]; + error?: string; +} + /** * Defines the Zod schema for a command definition file. This serves as the * single source of truth for both validation and type inference. @@ -141,6 +148,59 @@ export class FileCommandLoader implements ICommandLoader { return allCommands; } + /** + * Lists available .toml command files from user, project, and extension directories. + */ + async listAvailableFiles(): Promise { + const directories = this.getCommandDirectories(); + const groups: CommandFileGroup[] = []; + + for (const dir of directories) { + const displayName = this.getDisplayName(dir); + + try { + const files = await glob('**/*.toml', { cwd: dir.path }); + if (files.length > 0) { + groups.push({ + displayName, + path: dir.path, + files: [...files].sort(), + }); + } + } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + if ((e as { code?: string }).code === 'ENOENT') { + continue; + } + + groups.push({ + displayName, + path: dir.path, + files: [], + error: e instanceof Error ? e.message : String(e), + }); + } + } + + return groups; + } + + /** + * Returns a human-readable display name for the command directory source. + */ + private getDisplayName(dir: CommandDirectory): string { + switch (dir.kind) { + case CommandKind.USER_FILE: + return 'User'; + case CommandKind.WORKSPACE_FILE: + return 'Project'; + case CommandKind.EXTENSION_FILE: + return `Extension: ${dir.extensionName || 'Unknown'}`; + default: + return 'Custom'; + } + } + /** * Get all command directories in order for loading. * User commands → Project commands → Extension commands diff --git a/packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap b/packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap new file mode 100644 index 0000000000..ef3877d4a6 --- /dev/null +++ b/packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap @@ -0,0 +1,12 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`commandsCommand > list > should list .toml files from available sources 1`] = ` +"### User Commands (/mock/user/commands) +- user1.toml +### Project Commands (/mock/project/commands) +- proj1.toml +### Extension: ext1 Commands (/mock/ext1/commands) +- ext1.toml + +_Note: MCP prompts are dynamically loaded from configured MCP servers._" +`; diff --git a/packages/cli/src/ui/commands/commandsCommand.test.ts b/packages/cli/src/ui/commands/commandsCommand.test.ts index 3754292fe3..3ccbf36e33 100644 --- a/packages/cli/src/ui/commands/commandsCommand.test.ts +++ b/packages/cli/src/ui/commands/commandsCommand.test.ts @@ -4,11 +4,32 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Storage, type Config } from '@google/gemini-cli-core'; import { commandsCommand } from './commandsCommand.js'; import { MessageType } from '../types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; + +vi.mock('../../services/FileCommandLoader.js'); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual< + typeof import('@google/gemini-cli-core') + >('@google/gemini-cli-core'); + return { + ...actual, + Storage: class extends actual.Storage { + static override getUserCommandsDir() { + return '/mock/user/commands'; + } + override getProjectCommandsDir() { + return '/mock/project/commands'; + } + }, + }; +}); describe('commandsCommand', () => { let context: CommandContext; @@ -18,10 +39,27 @@ describe('commandsCommand', () => { context = createMockCommandContext({ ui: { reloadCommands: vi.fn(), + addItem: vi.fn(), + }, + services: { + agentContext: { + getProjectRoot: vi.fn().mockReturnValue('/mock/project'), + getFolderTrust: vi.fn().mockReturnValue(false), + isTrustedFolder: vi.fn().mockReturnValue(false), + getExtensions: vi.fn().mockReturnValue([ + { name: 'ext1', path: '/mock/ext1', isActive: true }, + { name: 'ext2', path: '/mock/ext2', isActive: false }, + ]), + storage: new Storage('/mock/project'), + } as unknown as Config, }, }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('default action', () => { it('should return an info message prompting subcommand usage', async () => { const result = await commandsCommand.action!(context, ''); @@ -30,7 +68,70 @@ describe('commandsCommand', () => { type: 'message', messageType: 'info', content: - 'Use "/commands reload" to reload custom command definitions from .toml files.', + 'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.', + }); + }); + }); + + describe('list', () => { + it('should list .toml files from available sources', async () => { + vi.mocked( + FileCommandLoader.prototype.listAvailableFiles, + ).mockResolvedValue([ + { + displayName: 'User', + path: '/mock/user/commands', + files: ['user1.toml'], + }, + { + displayName: 'Project', + path: '/mock/project/commands', + files: ['proj1.toml'], + }, + { + displayName: 'Extension: ext1', + path: '/mock/ext1/commands', + files: ['ext1.toml'], + }, + ]); + + const listCmd = commandsCommand.subCommands!.find( + (s) => s.name === 'list', + )!; + + await listCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.any(String), + }), + expect.any(Number), + ); + + // Snapshot the text content + const addItemCall = vi.mocked(context.ui.addItem).mock.calls[0][0]; + + expect((addItemCall as { text: string }).text).toMatchSnapshot(); + }); + + it('should show "No custom command files found" message if no .toml files exist', async () => { + vi.mocked( + FileCommandLoader.prototype.listAvailableFiles, + ).mockResolvedValue([]); + + const listCmd = commandsCommand.subCommands!.find( + (s) => s.name === 'list', + )!; + + const result = await listCmd.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'No custom command files (.toml) found.', + ), }); }); }); diff --git a/packages/cli/src/ui/commands/commandsCommand.ts b/packages/cli/src/ui/commands/commandsCommand.ts index bc4cc75544..32658b691a 100644 --- a/packages/cli/src/ui/commands/commandsCommand.ts +++ b/packages/cli/src/ui/commands/commandsCommand.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { type CommandContext, type SlashCommand, @@ -20,7 +21,7 @@ import { * Action for the default `/commands` invocation. * Displays a message prompting the user to use a subcommand. */ -async function listAction( +async function defaultAction( _context: CommandContext, _args: string, ): Promise { @@ -28,10 +29,64 @@ async function listAction( type: 'message', messageType: 'info', content: - 'Use "/commands reload" to reload custom command definitions from .toml files.', + 'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.', }; } +/** + * Action for `/commands list`. + * Lists available .toml command files from user, project, and extension directories. + */ +async function listSubcommandAction( + context: CommandContext, +): Promise { + try { + const config = context.services.agentContext?.config ?? null; + const loader = new FileCommandLoader(config); + const groups = await loader.listAvailableFiles(); + + const results: string[] = []; + for (const group of groups) { + results.push(`### ${group.displayName} Commands (${group.path})`); + if (group.error) { + results.push(`- (Error reading directory: ${group.error})`); + } else { + group.files.forEach((file) => results.push(`- ${file}`)); + } + } + + results.push( + '\n_Note: MCP prompts are dynamically loaded from configured MCP servers._', + ); + + if (results.length === 1) { + // Only the note is present + return { + type: 'message', + messageType: 'info', + content: + 'No custom command files (.toml) found.\n\n_Note: MCP prompts are dynamically loaded from configured MCP servers._', + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: results.join('\n'), + } as HistoryItemInfo, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to list commands: ${error instanceof Error ? error.message : String(error)}`, + } as HistoryItemError, + Date.now(), + ); + } +} + /** * Action for `/commands reload`. * Triggers a full re-discovery and reload of all slash commands, including @@ -63,10 +118,18 @@ async function reloadAction( export const commandsCommand: SlashCommand = { name: 'commands', - description: 'Manage custom slash commands. Usage: /commands [reload]', + description: 'Manage custom slash commands. Usage: /commands [list|reload]', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ + { + name: 'list', + description: + 'List available custom command .toml files. Usage: /commands list', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: listSubcommandAction, + }, { name: 'reload', altNames: ['refresh'], @@ -77,5 +140,5 @@ export const commandsCommand: SlashCommand = { action: reloadAction, }, ], - action: listAction, + action: defaultAction, };