Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cli/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/commands/models.test.ts
Original file line number Diff line number Diff line change
@@ -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 <command>');
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();
});
});
28 changes: 28 additions & 0 deletions packages/cli/src/commands/models.ts
Original file line number Diff line number Diff line change
@@ -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 <command>',
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.
},
};
126 changes: 126 additions & 0 deletions packages/cli/src/commands/models/list.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof process.stdout.write>;

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',
}),
);
});
});
});
95 changes: 95 additions & 0 deletions packages/cli/src/commands/models/list.ts
Original file line number Diff line number Diff line change
@@ -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<CliArgs> 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),
}));
Comment on lines +47 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The tokenLimit function can return undefined if a model's limit is not defined or if the model is unrecognized. Since inputTokenLimit in the ListedModel interface is strictly typed as a number, assigning a potentially undefined value will cause a TypeScript compilation error under strictNullChecks. Additionally, m.name may be optional in the model interface and should have a fallback to prevent potential runtime issues.

Suggested change
const models: ListedModel[] = availableModels.map((m) => ({
id: m.modelId,
name: m.name,
description: m.description ?? '',
inputTokenLimit: tokenLimit(m.modelId),
}));
const models: ListedModel[] = availableModels.map((m) => ({
id: m.modelId,
name: m.name ?? m.modelId,
description: m.description ?? '',
inputTokenLimit: tokenLimit(m.modelId) ?? 0,
}));
References
  1. When consuming an object, if a property is optional in its type definition (interface), callers must handle the undefined case (e.g., by providing a default with ??). Do not rely on the implementation details of the function that creates the object to always provide a value, as this can change. Code against the interface contract.


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();
},
Comment on lines +88 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If handleList throws an error (e.g., due to configuration initialization failure or network issues), the command handler will fail with an unhandled promise rejection. This can cause the CLI to crash abruptly without restoring the terminal state or performing proper cleanup. Wrapping the execution in a try/catch block and calling exitCli(1) ensures a graceful exit and clean terminal state restoration.

  handler: async (argv) => {
    try {
      await handleList({
        // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
        outputFormat: argv['outputFormat'] as ModelsListOutputFormat,
      });
      await exitCli();
    } catch (error) {
      console.error(chalk.red(error instanceof Error ? error.message : String(error)));
      await exitCli(1);
    }
  },

};
3 changes: 3 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -187,6 +188,7 @@ export async function parseArguments(
skillsCommand,
hooksCommand,
gemmaCommand,
modelsCommand,
];

const subcommands = commandModules.flatMap((mod) => {
Expand Down Expand Up @@ -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) =>
Expand Down
Loading