diff --git a/docs/cli/commands.md b/docs/cli/commands.md index ca7c055de5..c9546948f4 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -71,6 +71,17 @@ Slash commands provide meta-level control over the CLI itself. the visual display is cleared. - **Keyboard shortcut:** Press **Ctrl+L** at any time to perform a clear action. +### `/commands` + +- **Description:** Manage custom slash commands loaded from `.toml` files. +- **Sub-commands:** + - **`reload`**: + - **Description:** Reload custom command definitions from all sources + (user-level `~/.gemini/commands/`, project-level + `/.gemini/commands/`, MCP prompts, and extensions). Use this to + pick up new or modified `.toml` files without restarting the CLI. + - **Usage:** `/commands reload` + ### `/compress` - **Description:** Replace the entire chat context with a summary. This saves on diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index b70be823f1..e84839b8a3 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -30,6 +30,9 @@ separator (`/` or `\`) being converted to a colon (`:`). - A file at `/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`. +> [!TIP] After creating or modifying `.toml` command files, run +> `/commands reload` to pick up your changes without restarting the CLI. + ## TOML file format (v1) Your command definition files must be written in the TOML format and use the diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 0ae9ef3598..31673e921a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -23,6 +23,7 @@ import { authCommand } from '../ui/commands/authCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; +import { commandsCommand } from '../ui/commands/commandsCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; @@ -89,6 +90,7 @@ export class BuiltinCommandLoader implements ICommandLoader { : chatCommand.subCommands, }, clearCommand, + commandsCommand, compressCommand, copyCommand, corgiCommand, diff --git a/packages/cli/src/ui/commands/commandsCommand.test.ts b/packages/cli/src/ui/commands/commandsCommand.test.ts new file mode 100644 index 0000000000..3754292fe3 --- /dev/null +++ b/packages/cli/src/ui/commands/commandsCommand.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { commandsCommand } from './commandsCommand.js'; +import { MessageType } from '../types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from './types.js'; + +describe('commandsCommand', () => { + let context: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + context = createMockCommandContext({ + ui: { + reloadCommands: vi.fn(), + }, + }); + }); + + describe('default action', () => { + it('should return an info message prompting subcommand usage', async () => { + const result = await commandsCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Use "/commands reload" to reload custom command definitions from .toml files.', + }); + }); + }); + + describe('reload', () => { + it('should call reloadCommands and show a success message', async () => { + const reloadCmd = commandsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + + await reloadCmd.action!(context, ''); + + expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Custom commands reloaded successfully.', + }), + expect.any(Number), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/commandsCommand.ts b/packages/cli/src/ui/commands/commandsCommand.ts new file mode 100644 index 0000000000..dc7fe4651d --- /dev/null +++ b/packages/cli/src/ui/commands/commandsCommand.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { + MessageType, + type HistoryItemError, + type HistoryItemInfo, +} from '../types.js'; + +/** + * Action for the default `/commands` invocation. + * Displays a message prompting the user to use a subcommand. + */ +async function listAction( + _context: CommandContext, + _args: string, +): Promise { + return { + type: 'message', + messageType: 'info', + content: + 'Use "/commands reload" to reload custom command definitions from .toml files.', + }; +} + +/** + * Action for `/commands reload`. + * Triggers a full re-discovery and reload of all slash commands, including + * user/project-level .toml files, MCP prompts, and extension commands. + */ +async function reloadAction( + context: CommandContext, +): Promise { + try { + context.ui.reloadCommands(); + + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Custom commands reloaded successfully.', + } as HistoryItemInfo, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to reload commands: ${error instanceof Error ? error.message : String(error)}`, + } as HistoryItemError, + Date.now(), + ); + } +} + +export const commandsCommand: SlashCommand = { + name: 'commands', + description: 'Manage custom slash commands. Usage: /commands [reload]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'reload', + description: + 'Reload custom command definitions from .toml files. Usage: /commands reload', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: reloadAction, + }, + ], + action: listAction, +};