Skip to content

Commit 8461662

Browse files
Jwhyeecocosheng-gspencer426
authored
feat(cli): Add 'list' subcommand to '/commands' (google-gemini#22324)
Co-authored-by: Coco Sheng <cocosheng@google.com> Co-authored-by: Spencer <spencertang@google.com>
1 parent ef040eb commit 8461662

7 files changed

Lines changed: 250 additions & 7 deletions

File tree

docs/cli/cli-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ These commands are available within the interactive REPL.
3333
| -------------------- | ----------------------------------------------- |
3434
| `/skills reload` | Reload discovered skills from disk |
3535
| `/agents reload` | Reload the agent registry |
36+
| `/commands list` | List available custom slash commands |
3637
| `/commands reload` | Reload custom slash commands |
3738
| `/memory reload` | Reload context files (for example, `GEMINI.md`) |
3839
| `/mcp reload` | Restart and reload MCP servers |

docs/cli/custom-commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ separator (`/` or `\`) being converted to a colon (`:`).
3434
> [!TIP]
3535
> After creating or modifying `.toml` command files, run
3636
> `/commands reload` to pick up your changes without restarting the CLI.
37+
> To see all available command files, run `/commands list`.
3738
3839
## TOML file format (v1)
3940

docs/reference/commands.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ Slash commands provide meta-level control over the CLI itself.
111111

112112
- **Description:** Manage custom slash commands loaded from `.toml` files.
113113
- **Sub-commands:**
114+
- **`list`**:
115+
- **Description:** List available custom command `.toml` files from all
116+
sources (user-level `~/.gemini/commands/`, project-level
117+
`<project>/.gemini/commands/`, and active extensions).
118+
- **Usage:** `/commands list`
114119
- **`reload`**:
115120
- **Description:** Reload custom command definitions from all sources
116121
(user-level `~/.gemini/commands/`, project-level

packages/cli/src/services/FileCommandLoader.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,20 @@ import {
3434
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
3535
import { sanitizeForDisplay } from '../ui/utils/textUtils.js';
3636

37-
interface CommandDirectory {
37+
export interface CommandDirectory {
3838
path: string;
3939
kind: CommandKind;
4040
extensionName?: string;
4141
extensionId?: string;
4242
}
4343

44+
export interface CommandFileGroup {
45+
displayName: string;
46+
path: string;
47+
files: string[];
48+
error?: string;
49+
}
50+
4451
/**
4552
* Defines the Zod schema for a command definition file. This serves as the
4653
* single source of truth for both validation and type inference.
@@ -141,6 +148,59 @@ export class FileCommandLoader implements ICommandLoader {
141148
return allCommands;
142149
}
143150

151+
/**
152+
* Lists available .toml command files from user, project, and extension directories.
153+
*/
154+
async listAvailableFiles(): Promise<CommandFileGroup[]> {
155+
const directories = this.getCommandDirectories();
156+
const groups: CommandFileGroup[] = [];
157+
158+
for (const dir of directories) {
159+
const displayName = this.getDisplayName(dir);
160+
161+
try {
162+
const files = await glob('**/*.toml', { cwd: dir.path });
163+
if (files.length > 0) {
164+
groups.push({
165+
displayName,
166+
path: dir.path,
167+
files: [...files].sort(),
168+
});
169+
}
170+
} catch (e) {
171+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
172+
if ((e as { code?: string }).code === 'ENOENT') {
173+
continue;
174+
}
175+
176+
groups.push({
177+
displayName,
178+
path: dir.path,
179+
files: [],
180+
error: e instanceof Error ? e.message : String(e),
181+
});
182+
}
183+
}
184+
185+
return groups;
186+
}
187+
188+
/**
189+
* Returns a human-readable display name for the command directory source.
190+
*/
191+
private getDisplayName(dir: CommandDirectory): string {
192+
switch (dir.kind) {
193+
case CommandKind.USER_FILE:
194+
return 'User';
195+
case CommandKind.WORKSPACE_FILE:
196+
return 'Project';
197+
case CommandKind.EXTENSION_FILE:
198+
return `Extension: ${dir.extensionName || 'Unknown'}`;
199+
default:
200+
return 'Custom';
201+
}
202+
}
203+
144204
/**
145205
* Get all command directories in order for loading.
146206
* User commands → Project commands → Extension commands
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`commandsCommand > list > should list .toml files from available sources 1`] = `
4+
"### User Commands (/mock/user/commands)
5+
- user1.toml
6+
### Project Commands (/mock/project/commands)
7+
- proj1.toml
8+
### Extension: ext1 Commands (/mock/ext1/commands)
9+
- ext1.toml
10+
11+
_Note: MCP prompts are dynamically loaded from configured MCP servers._"
12+
`;

packages/cli/src/ui/commands/commandsCommand.test.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,32 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, it, expect, vi, beforeEach } from 'vitest';
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import { Storage, type Config } from '@google/gemini-cli-core';
89
import { commandsCommand } from './commandsCommand.js';
910
import { MessageType } from '../types.js';
1011
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
1112
import type { CommandContext } from './types.js';
13+
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
14+
15+
vi.mock('../../services/FileCommandLoader.js');
16+
17+
vi.mock('@google/gemini-cli-core', async () => {
18+
const actual = await vi.importActual<
19+
typeof import('@google/gemini-cli-core')
20+
>('@google/gemini-cli-core');
21+
return {
22+
...actual,
23+
Storage: class extends actual.Storage {
24+
static override getUserCommandsDir() {
25+
return '/mock/user/commands';
26+
}
27+
override getProjectCommandsDir() {
28+
return '/mock/project/commands';
29+
}
30+
},
31+
};
32+
});
1233

1334
describe('commandsCommand', () => {
1435
let context: CommandContext;
@@ -18,10 +39,27 @@ describe('commandsCommand', () => {
1839
context = createMockCommandContext({
1940
ui: {
2041
reloadCommands: vi.fn(),
42+
addItem: vi.fn(),
43+
},
44+
services: {
45+
agentContext: {
46+
getProjectRoot: vi.fn().mockReturnValue('/mock/project'),
47+
getFolderTrust: vi.fn().mockReturnValue(false),
48+
isTrustedFolder: vi.fn().mockReturnValue(false),
49+
getExtensions: vi.fn().mockReturnValue([
50+
{ name: 'ext1', path: '/mock/ext1', isActive: true },
51+
{ name: 'ext2', path: '/mock/ext2', isActive: false },
52+
]),
53+
storage: new Storage('/mock/project'),
54+
} as unknown as Config,
2155
},
2256
});
2357
});
2458

59+
afterEach(() => {
60+
vi.restoreAllMocks();
61+
});
62+
2563
describe('default action', () => {
2664
it('should return an info message prompting subcommand usage', async () => {
2765
const result = await commandsCommand.action!(context, '');
@@ -30,7 +68,70 @@ describe('commandsCommand', () => {
3068
type: 'message',
3169
messageType: 'info',
3270
content:
33-
'Use "/commands reload" to reload custom command definitions from .toml files.',
71+
'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
72+
});
73+
});
74+
});
75+
76+
describe('list', () => {
77+
it('should list .toml files from available sources', async () => {
78+
vi.mocked(
79+
FileCommandLoader.prototype.listAvailableFiles,
80+
).mockResolvedValue([
81+
{
82+
displayName: 'User',
83+
path: '/mock/user/commands',
84+
files: ['user1.toml'],
85+
},
86+
{
87+
displayName: 'Project',
88+
path: '/mock/project/commands',
89+
files: ['proj1.toml'],
90+
},
91+
{
92+
displayName: 'Extension: ext1',
93+
path: '/mock/ext1/commands',
94+
files: ['ext1.toml'],
95+
},
96+
]);
97+
98+
const listCmd = commandsCommand.subCommands!.find(
99+
(s) => s.name === 'list',
100+
)!;
101+
102+
await listCmd.action!(context, '');
103+
104+
expect(context.ui.addItem).toHaveBeenCalledWith(
105+
expect.objectContaining({
106+
type: MessageType.INFO,
107+
text: expect.any(String),
108+
}),
109+
expect.any(Number),
110+
);
111+
112+
// Snapshot the text content
113+
const addItemCall = vi.mocked(context.ui.addItem).mock.calls[0][0];
114+
115+
expect((addItemCall as { text: string }).text).toMatchSnapshot();
116+
});
117+
118+
it('should show "No custom command files found" message if no .toml files exist', async () => {
119+
vi.mocked(
120+
FileCommandLoader.prototype.listAvailableFiles,
121+
).mockResolvedValue([]);
122+
123+
const listCmd = commandsCommand.subCommands!.find(
124+
(s) => s.name === 'list',
125+
)!;
126+
127+
const result = await listCmd.action!(context, '');
128+
129+
expect(result).toEqual({
130+
type: 'message',
131+
messageType: 'info',
132+
content: expect.stringContaining(
133+
'No custom command files (.toml) found.',
134+
),
34135
});
35136
});
36137
});

packages/cli/src/ui/commands/commandsCommand.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
78
import {
89
type CommandContext,
910
type SlashCommand,
@@ -20,18 +21,72 @@ import {
2021
* Action for the default `/commands` invocation.
2122
* Displays a message prompting the user to use a subcommand.
2223
*/
23-
async function listAction(
24+
async function defaultAction(
2425
_context: CommandContext,
2526
_args: string,
2627
): Promise<void | SlashCommandActionReturn> {
2728
return {
2829
type: 'message',
2930
messageType: 'info',
3031
content:
31-
'Use "/commands reload" to reload custom command definitions from .toml files.',
32+
'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
3233
};
3334
}
3435

36+
/**
37+
* Action for `/commands list`.
38+
* Lists available .toml command files from user, project, and extension directories.
39+
*/
40+
async function listSubcommandAction(
41+
context: CommandContext,
42+
): Promise<void | SlashCommandActionReturn> {
43+
try {
44+
const config = context.services.agentContext?.config ?? null;
45+
const loader = new FileCommandLoader(config);
46+
const groups = await loader.listAvailableFiles();
47+
48+
const results: string[] = [];
49+
for (const group of groups) {
50+
results.push(`### ${group.displayName} Commands (${group.path})`);
51+
if (group.error) {
52+
results.push(`- (Error reading directory: ${group.error})`);
53+
} else {
54+
group.files.forEach((file) => results.push(`- ${file}`));
55+
}
56+
}
57+
58+
results.push(
59+
'\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
60+
);
61+
62+
if (results.length === 1) {
63+
// Only the note is present
64+
return {
65+
type: 'message',
66+
messageType: 'info',
67+
content:
68+
'No custom command files (.toml) found.\n\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
69+
};
70+
}
71+
72+
context.ui.addItem(
73+
{
74+
type: MessageType.INFO,
75+
text: results.join('\n'),
76+
} as HistoryItemInfo,
77+
Date.now(),
78+
);
79+
} catch (error) {
80+
context.ui.addItem(
81+
{
82+
type: MessageType.ERROR,
83+
text: `Failed to list commands: ${error instanceof Error ? error.message : String(error)}`,
84+
} as HistoryItemError,
85+
Date.now(),
86+
);
87+
}
88+
}
89+
3590
/**
3691
* Action for `/commands reload`.
3792
* Triggers a full re-discovery and reload of all slash commands, including
@@ -63,10 +118,18 @@ async function reloadAction(
63118

64119
export const commandsCommand: SlashCommand = {
65120
name: 'commands',
66-
description: 'Manage custom slash commands. Usage: /commands [reload]',
121+
description: 'Manage custom slash commands. Usage: /commands [list|reload]',
67122
kind: CommandKind.BUILT_IN,
68123
autoExecute: false,
69124
subCommands: [
125+
{
126+
name: 'list',
127+
description:
128+
'List available custom command .toml files. Usage: /commands list',
129+
kind: CommandKind.BUILT_IN,
130+
autoExecute: true,
131+
action: listSubcommandAction,
132+
},
70133
{
71134
name: 'reload',
72135
altNames: ['refresh'],
@@ -77,5 +140,5 @@ export const commandsCommand: SlashCommand = {
77140
action: reloadAction,
78141
},
79142
],
80-
action: listAction,
143+
action: defaultAction,
81144
};

0 commit comments

Comments
 (0)