mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 23:14:32 -07:00
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:
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user