Skip to content

Commit 514767c

Browse files
authored
Structured JSON Output (#8119)
1 parent db99fc7 commit 514767c

20 files changed

Lines changed: 1526 additions & 23 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,19 @@ gemini -m gemini-2.5-flash
191191

192192
#### Non-interactive mode for scripts
193193

194+
Get a simple text response:
195+
194196
```bash
195197
gemini -p "Explain the architecture of this codebase"
196198
```
197199

200+
For more advanced scripting, including how to parse JSON and handle errors, use
201+
the `--output-format json` flag to get structured output:
202+
203+
```bash
204+
gemini -p "Explain the architecture of this codebase" --output-format json
205+
```
206+
198207
### Quick Examples
199208

200209
#### Start a new project

docs/cli/configuration.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ Settings are organized into categories. All settings should be placed within the
7676
- **Description:** Enable session checkpointing for recovery.
7777
- **Default:** `false`
7878

79+
#### `output`
80+
81+
- **`output.format`** (string):
82+
- **Description:** The format of the CLI output.
83+
- **Default:** `"text"`
84+
- **Values:** `"text"`, `"json"`
85+
7986
#### `ui`
8087

8188
- **`ui.theme`** (string):
@@ -442,11 +449,18 @@ Arguments passed directly when running the CLI can override other configurations
442449
- Example: `npm start -- --model gemini-1.5-pro-latest`
443450
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
444451
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a non-interactive mode.
452+
- For scripting examples, use the `--output-format json` flag to get structured output.
445453
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
446454
- Starts an interactive session with the provided prompt as the initial input.
447455
- The prompt is processed within the interactive session, not before it.
448456
- Cannot be used when piping input from stdin.
449457
- Example: `gemini -i "explain this code"`
458+
- **`--output-format <format>`**:
459+
- **Description:** Specifies the format of the CLI output for non-interactive mode.
460+
- **Values:**
461+
- `text`: (Default) The standard human-readable output.
462+
- `json`: A machine-readable JSON output.
463+
- **Note:** For structured output and scripting, use the `--output-format json` flag.
450464
- **`--sandbox`** (**`-s`**):
451465
- Enables sandbox mode for this session.
452466
- **`--sandbox-image`**:

docs/cli/index.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,19 @@ Gemini CLI executes the command and prints the output to your terminal. Note tha
2727
```bash
2828
gemini -p "What is fine tuning?"
2929
```
30+
31+
For non-interactive usage with structured output, use the `--output-format json` flag for scripting and automation.
32+
33+
Get structured JSON output for scripting:
34+
35+
```bash
36+
gemini -p "What is fine tuning?" --output-format json
37+
# Output:
38+
# {
39+
# "response": "Fine tuning is...",
40+
# "stats": {
41+
# "models": { "gemini-2.5-flash": { "tokens": {"total": 45} } }
42+
# },
43+
# "error": null
44+
# }
45+
```
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
8+
import { TestRig } from './test-helper.js';
9+
10+
describe('JSON output', () => {
11+
let rig: TestRig;
12+
13+
beforeEach(async () => {
14+
rig = new TestRig();
15+
await rig.setup('json-output-test');
16+
});
17+
18+
afterEach(async () => {
19+
await rig.cleanup();
20+
});
21+
22+
it('should return a valid JSON with response and stats', async () => {
23+
const result = await rig.run(
24+
'What is the capital of France?',
25+
'--output-format',
26+
'json',
27+
);
28+
const parsed = JSON.parse(result);
29+
30+
expect(parsed).toHaveProperty('response');
31+
expect(typeof parsed.response).toBe('string');
32+
expect(parsed.response.toLowerCase()).toContain('paris');
33+
34+
expect(parsed).toHaveProperty('stats');
35+
expect(typeof parsed.stats).toBe('object');
36+
});
37+
});

integration-tests/test-helper.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,15 @@ export class TestRig {
284284

285285
result = filteredLines.join('\n');
286286
}
287-
// If we have stderr output, include that also
288-
if (stderr) {
287+
288+
// Check if this is a JSON output test - if so, don't include stderr
289+
// as it would corrupt the JSON
290+
const isJsonOutput =
291+
commandArgs.includes('--output-format') &&
292+
commandArgs.includes('json');
293+
294+
// If we have stderr output and it's not a JSON test, include that also
295+
if (stderr && !isJsonOutput) {
289296
result += `\n\nStdErr:\n${stderr}`;
290297
}
291298

packages/cli/src/config/config.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1972,6 +1972,55 @@ describe('loadCliConfig fileFiltering', () => {
19721972
);
19731973
});
19741974

1975+
describe('Output Format Configuration', () => {
1976+
const originalArgv = process.argv;
1977+
1978+
afterEach(() => {
1979+
process.argv = originalArgv;
1980+
vi.restoreAllMocks();
1981+
});
1982+
1983+
it('should default to text format when no setting or flag is provided', async () => {
1984+
process.argv = ['node', 'script.js'];
1985+
const argv = await parseArguments({} as Settings);
1986+
const config = await loadCliConfig(
1987+
{} as Settings,
1988+
[],
1989+
'test-session',
1990+
argv,
1991+
);
1992+
expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.TEXT);
1993+
});
1994+
1995+
it('should use the format from settings when no flag is provided', async () => {
1996+
process.argv = ['node', 'script.js'];
1997+
const settings: Settings = { output: { format: 'json' } };
1998+
const argv = await parseArguments(settings);
1999+
const config = await loadCliConfig(settings, [], 'test-session', argv);
2000+
expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.JSON);
2001+
});
2002+
2003+
it('should use the format from the flag when provided', async () => {
2004+
process.argv = ['node', 'script.js', '--output-format', 'json'];
2005+
const argv = await parseArguments({} as Settings);
2006+
const config = await loadCliConfig(
2007+
{} as Settings,
2008+
[],
2009+
'test-session',
2010+
argv,
2011+
);
2012+
expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.JSON);
2013+
});
2014+
2015+
it('should prioritize the flag over the setting', async () => {
2016+
process.argv = ['node', 'script.js', '--output-format', 'text'];
2017+
const settings: Settings = { output: { format: 'json' } };
2018+
const argv = await parseArguments(settings);
2019+
const config = await loadCliConfig(settings, [], 'test-session', argv);
2020+
expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.TEXT);
2021+
});
2022+
});
2023+
19752024
describe('parseArguments with positional prompt', () => {
19762025
const originalArgv = process.argv;
19772026

packages/cli/src/config/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
TelemetryTarget,
1616
FileFilteringOptions,
1717
MCPServerConfig,
18+
OutputFormat,
1819
} from '@google/gemini-cli-core';
1920
import { extensionsCommand } from '../commands/extensions.js';
2021
import {
@@ -81,6 +82,7 @@ export interface CliArgs {
8182
useSmartEdit: boolean | undefined;
8283
sessionSummary: string | undefined;
8384
promptWords: string[] | undefined;
85+
outputFormat: string | undefined;
8486
}
8587

8688
export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -234,6 +236,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
234236
type: 'string',
235237
description: 'File to write session summary to.',
236238
})
239+
.option('output-format', {
240+
type: 'string',
241+
description: 'The format of the CLI output.',
242+
choices: ['text', 'json'],
243+
})
237244
.deprecateOption(
238245
'telemetry',
239246
'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.',
@@ -627,6 +634,9 @@ export async function loadCliConfig(
627634
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
628635
eventEmitter: appEvents,
629636
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
637+
output: {
638+
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
639+
},
630640
});
631641
}
632642

packages/cli/src/config/settings.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,30 @@ describe('Settings Loading and Merging', () => {
10781078
});
10791079
});
10801080

1081+
it('should merge output format settings, with workspace taking precedence', () => {
1082+
(mockFsExistsSync as Mock).mockReturnValue(true);
1083+
const userSettingsContent = {
1084+
output: { format: 'text' },
1085+
};
1086+
const workspaceSettingsContent = {
1087+
output: { format: 'json' },
1088+
};
1089+
1090+
(fs.readFileSync as Mock).mockImplementation(
1091+
(p: fs.PathOrFileDescriptor) => {
1092+
if (p === USER_SETTINGS_PATH)
1093+
return JSON.stringify(userSettingsContent);
1094+
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
1095+
return JSON.stringify(workspaceSettingsContent);
1096+
return '{}';
1097+
},
1098+
);
1099+
1100+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
1101+
1102+
expect(settings.merged.output?.format).toBe('json');
1103+
});
1104+
10811105
it('should handle chatCompression when only in user settings', () => {
10821106
(mockFsExistsSync as Mock).mockImplementation(
10831107
(p: fs.PathLike) => p === USER_SETTINGS_PATH,

packages/cli/src/config/settingsSchema.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,30 @@ const SETTINGS_SCHEMA = {
187187
},
188188
},
189189
},
190+
output: {
191+
type: 'object',
192+
label: 'Output',
193+
category: 'General',
194+
requiresRestart: false,
195+
default: {},
196+
description: 'Settings for the CLI output.',
197+
showInDialog: false,
198+
properties: {
199+
format: {
200+
type: 'enum',
201+
label: 'Output Format',
202+
category: 'General',
203+
requiresRestart: false,
204+
default: 'text',
205+
description: 'The format of the CLI output.',
206+
showInDialog: true,
207+
options: [
208+
{ value: 'text', label: 'Text' },
209+
{ value: 'json', label: 'JSON' },
210+
],
211+
},
212+
},
213+
},
190214

191215
ui: {
192216
type: 'object',

packages/cli/src/gemini.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ describe('gemini.tsx main function kitty protocol', () => {
235235
useSmartEdit: undefined,
236236
sessionSummary: undefined,
237237
promptWords: undefined,
238+
outputFormat: undefined,
238239
});
239240

240241
await main();

0 commit comments

Comments
 (0)