feat(cli): Add 'list' subcommand to '/commands' (#22324)

Co-authored-by: Coco Sheng <cocosheng@google.com>
Co-authored-by: Spencer <spencertang@google.com>
This commit is contained in:
JunYoung Ka
2026-05-01 04:54:17 +09:00
committed by GitHub
parent ef040eb392
commit 84616626f5
7 changed files with 250 additions and 7 deletions
+1
View File
@@ -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 |
+1
View File
@@ -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)
+5
View File
@@ -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
`<project>/.gemini/commands/`, and active extensions).
- **Usage:** `/commands list`
- **`reload`**:
- **Description:** Reload custom command definitions from all sources
(user-level `~/.gemini/commands/`, project-level
+61 -1
View File
@@ -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<CommandFileGroup[]> {
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
@@ -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._"
`;
@@ -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.',
),
});
});
});
@@ -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<void | SlashCommandActionReturn> {
@@ -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<void | SlashCommandActionReturn> {
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,
};