diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index a3d17c0a778..5b829b89961 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -18,6 +18,7 @@ and parameters. | `gemini update` | Update to latest version | `gemini update` | | `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) | | `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) | +| `gemini models list` | List available models | `gemini models list -o json` | ### Positional arguments diff --git a/packages/cli/src/commands/models.test.ts b/packages/cli/src/commands/models.test.ts new file mode 100644 index 00000000000..8858c2fdfaf --- /dev/null +++ b/packages/cli/src/commands/models.test.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { modelsCommand } from './models.js'; + +vi.mock('./models/list.js', () => ({ listCommand: { command: 'list' } })); + +vi.mock('../gemini.js', () => ({ + initializeOutputListenersAndFlush: vi.fn(), +})); + +describe('modelsCommand', () => { + it('should have correct command and description', () => { + expect(modelsCommand.command).toBe('models '); + expect(modelsCommand.describe).toBe('Inspect available models.'); + }); + + it('should register the list subcommand in builder', () => { + const mockYargs = { + middleware: vi.fn().mockReturnThis(), + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + + // @ts-expect-error - Mocking yargs + modelsCommand.builder(mockYargs); + + expect(mockYargs.middleware).toHaveBeenCalled(); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'list' }), + ); + expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); + expect(mockYargs.version).toHaveBeenCalledWith(false); + }); + + it('should have a handler that does nothing', () => { + // @ts-expect-error - Handler doesn't take arguments in this case + expect(modelsCommand.handler()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/models.ts b/packages/cli/src/commands/models.ts new file mode 100644 index 00000000000..e18ca1d9c72 --- /dev/null +++ b/packages/cli/src/commands/models.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { listCommand } from './models/list.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; + +export const modelsCommand: CommandModule = { + command: 'models ', + describe: 'Inspect available models.', + builder: (yargs) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(listCommand, 'models')) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/models/list.test.ts b/packages/cli/src/commands/models/list.test.ts new file mode 100644 index 00000000000..5a3ab6b677d --- /dev/null +++ b/packages/cli/src/commands/models/list.test.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { type Config } from '@google/gemini-cli-core'; +import { handleList, listCommand } from './list.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import { loadCliConfig } from '../../config/config.js'; +import { buildAvailableModels } from '../../acp/acpUtils.js'; + +vi.mock('../../config/settings.js'); +vi.mock('../../config/config.js'); +vi.mock('../../acp/acpUtils.js', () => ({ buildAvailableModels: vi.fn() })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('models list command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockLoadCliConfig = vi.mocked(loadCliConfig); + const mockBuildAvailableModels = vi.mocked(buildAvailableModels); + let stdoutWriteSpy: MockInstance; + + const written = () => + stdoutWriteSpy.mock.calls.map((call) => String(call[0])).join(''); + + beforeEach(() => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + mockLoadCliConfig.mockResolvedValue({ + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as Config); + mockBuildAvailableModels.mockReturnValue({ + currentModelId: 'gemini-2.5-pro', + availableModels: [ + { + modelId: 'auto', + name: 'Auto', + description: 'Let Gemini CLI decide', + }, + { modelId: 'gemini-2.5-pro', name: 'gemini-2.5-pro' }, + ], + }); + stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleList', () => { + it('prints machine-readable JSON with id, name, description and input limit', async () => { + await handleList({ outputFormat: 'json' }); + + const parsed = JSON.parse(written()); + expect(parsed).toEqual({ + currentModel: 'gemini-2.5-pro', + models: [ + { + id: 'auto', + name: 'Auto', + description: 'Let Gemini CLI decide', + inputTokenLimit: 1_048_576, + }, + { + id: 'gemini-2.5-pro', + name: 'gemini-2.5-pro', + description: '', + inputTokenLimit: 1_048_576, + }, + ], + }); + }); + + it('emits only JSON on stdout when json is requested', async () => { + await handleList({ outputFormat: 'json' }); + + expect(() => JSON.parse(written())).not.toThrow(); + }); + + it('prints a human-readable list by default and marks the current model', async () => { + await handleList({}); + + const output = written(); + expect(output).toContain('Available Models:'); + expect(output).toContain('auto'); + expect(output).toContain('Let Gemini CLI decide'); + expect(output).toContain('(current)'); + expect(output).toContain('1,048,576 tokens'); + }); + }); + + describe('listCommand', () => { + it('is registered as "list" and exposes an output-format option', () => { + expect(listCommand.command).toBe('list'); + + const mockYargs = { option: vi.fn().mockReturnThis() }; + // @ts-expect-error - Mocking yargs + listCommand.builder(mockYargs); + + expect(mockYargs.option).toHaveBeenCalledWith( + 'output-format', + expect.objectContaining({ + alias: 'o', + choices: ['text', 'json'], + default: 'text', + }), + ); + }); + }); +}); diff --git a/packages/cli/src/commands/models/list.ts b/packages/cli/src/commands/models/list.ts new file mode 100644 index 00000000000..cf34b826020 --- /dev/null +++ b/packages/cli/src/commands/models/list.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { tokenLimit } from '@google/gemini-cli-core'; +import { loadSettings } from '../../config/settings.js'; +import { loadCliConfig, type CliArgs } from '../../config/config.js'; +import { buildAvailableModels } from '../../acp/acpUtils.js'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +export type ModelsListOutputFormat = 'text' | 'json'; + +export interface ListedModel { + id: string; + name: string; + description: string; + inputTokenLimit: number; +} + +export async function handleList(args: { + outputFormat?: ModelsListOutputFormat; +}) { + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const config = await loadCliConfig( + settings.merged, + 'models-list-session', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + debug: false, + } as Partial as CliArgs, + { cwd: workspaceDir }, + ); + + await config.initialize(); + + const { availableModels, currentModelId } = buildAvailableModels( + config, + settings, + ); + + const models: ListedModel[] = availableModels.map((m) => ({ + id: m.modelId, + name: m.name, + description: m.description ?? '', + inputTokenLimit: tokenLimit(m.modelId), + })); + + if (args.outputFormat === 'json') { + process.stdout.write( + JSON.stringify({ currentModel: currentModelId, models }, null, 2) + '\n', + ); + return; + } + + process.stdout.write(chalk.bold('Available Models:') + '\n\n'); + for (const model of models) { + const current = + model.id === currentModelId ? chalk.green(' (current)') : ''; + process.stdout.write(`${chalk.bold(model.id)}${current}\n`); + process.stdout.write(` Name: ${model.name}\n`); + if (model.description) { + process.stdout.write(` Description: ${model.description}\n`); + } + process.stdout.write( + ` Input limit: ${model.inputTokenLimit.toLocaleString('en-US')} tokens\n\n`, + ); + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: + 'Lists the models available for use with the -m/--model flag, based on your current configuration.', + builder: (yargs) => + yargs.option('output-format', { + alias: 'o', + type: 'string', + choices: ['text', 'json'], + default: 'text', + description: 'The format of the output.', + }), + handler: async (argv) => { + await handleList({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + outputFormat: argv['outputFormat'] as ModelsListOutputFormat, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f3e8a662296..207e2d73da6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -14,6 +14,7 @@ import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; import { hooksCommand } from '../commands/hooks.js'; import { gemmaCommand } from '../commands/gemma.js'; +import { modelsCommand } from '../commands/models.js'; import { setGeminiMdFilename as setServerGeminiMdFilename, resetGeminiMdFilename, @@ -187,6 +188,7 @@ export async function parseArguments( skillsCommand, hooksCommand, gemmaCommand, + modelsCommand, ]; const subcommands = commandModules.flatMap((mod) => { @@ -277,6 +279,7 @@ export async function parseArguments( yargsInstance.command(skillsCommand); yargsInstance.command(hooksCommand); yargsInstance.command(gemmaCommand); + yargsInstance.command(modelsCommand); yargsInstance .command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>