mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(cli) Custom Commands work in Non-Interactive/Headless Mode (#8305)
This commit is contained in:
@@ -465,7 +465,7 @@ export async function main() {
|
|||||||
console.log('Session ID: %s', sessionId);
|
console.log('Session ID: %s', sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
|
await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id);
|
||||||
// Call cleanup before process.exit, which causes cleanup to not run
|
// Call cleanup before process.exit, which causes cleanup to not run
|
||||||
await runExitCleanup();
|
await runExitCleanup();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import type { Part } from '@google/genai';
|
import type { Part } from '@google/genai';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
|
|
||||||
// Mock core modules
|
// Mock core modules
|
||||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||||
@@ -48,8 +49,17 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockGetCommands = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('./services/CommandService.js', () => ({
|
||||||
|
CommandService: {
|
||||||
|
create: mockCommandServiceCreate,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('runNonInteractive', () => {
|
describe('runNonInteractive', () => {
|
||||||
let mockConfig: Config;
|
let mockConfig: Config;
|
||||||
|
let mockSettings: LoadedSettings;
|
||||||
let mockToolRegistry: ToolRegistry;
|
let mockToolRegistry: ToolRegistry;
|
||||||
let mockCoreExecuteToolCall: vi.Mock;
|
let mockCoreExecuteToolCall: vi.Mock;
|
||||||
let mockShutdownTelemetry: vi.Mock;
|
let mockShutdownTelemetry: vi.Mock;
|
||||||
@@ -64,6 +74,10 @@ describe('runNonInteractive', () => {
|
|||||||
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
||||||
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
|
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
|
||||||
|
|
||||||
|
mockCommandServiceCreate.mockResolvedValue({
|
||||||
|
getCommands: mockGetCommands,
|
||||||
|
});
|
||||||
|
|
||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
processStdoutSpy = vi
|
processStdoutSpy = vi
|
||||||
.spyOn(process.stdout, 'write')
|
.spyOn(process.stdout, 'write')
|
||||||
@@ -102,8 +116,30 @@ describe('runNonInteractive', () => {
|
|||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||||
getDebugMode: vi.fn().mockReturnValue(false),
|
getDebugMode: vi.fn().mockReturnValue(false),
|
||||||
getOutputFormat: vi.fn().mockReturnValue('text'),
|
getOutputFormat: vi.fn().mockReturnValue('text'),
|
||||||
|
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||||
|
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
|
mockSettings = {
|
||||||
|
system: { path: '', settings: {} },
|
||||||
|
systemDefaults: { path: '', settings: {} },
|
||||||
|
user: { path: '', settings: {} },
|
||||||
|
workspace: { path: '', settings: {} },
|
||||||
|
errors: [],
|
||||||
|
setValue: vi.fn(),
|
||||||
|
merged: {
|
||||||
|
security: {
|
||||||
|
auth: {
|
||||||
|
enforcedType: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isTrusted: true,
|
||||||
|
migratedInMemorScopes: new Set(),
|
||||||
|
forScope: vi.fn(),
|
||||||
|
computeMergedSettings: vi.fn(),
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
const { handleAtCommand } = await import(
|
const { handleAtCommand } = await import(
|
||||||
'./ui/hooks/atCommandProcessor.js'
|
'./ui/hooks/atCommandProcessor.js'
|
||||||
);
|
);
|
||||||
@@ -138,7 +174,12 @@ describe('runNonInteractive', () => {
|
|||||||
createStreamFromEvents(events),
|
createStreamFromEvents(events),
|
||||||
);
|
);
|
||||||
|
|
||||||
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1');
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Test input',
|
||||||
|
'prompt-id-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||||
[{ text: 'Test input' }],
|
[{ text: 'Test input' }],
|
||||||
@@ -178,7 +219,12 @@ describe('runNonInteractive', () => {
|
|||||||
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
||||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||||
|
|
||||||
await runNonInteractive(mockConfig, 'Use a tool', 'prompt-id-2');
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Use a tool',
|
||||||
|
'prompt-id-2',
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||||
@@ -236,7 +282,12 @@ describe('runNonInteractive', () => {
|
|||||||
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
||||||
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
||||||
|
|
||||||
await runNonInteractive(mockConfig, 'Trigger tool error', 'prompt-id-3');
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Trigger tool error',
|
||||||
|
'prompt-id-3',
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
@@ -268,7 +319,12 @@ describe('runNonInteractive', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'),
|
runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Initial fail',
|
||||||
|
'prompt-id-4',
|
||||||
|
),
|
||||||
).rejects.toThrow(apiError);
|
).rejects.toThrow(apiError);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,6 +361,7 @@ describe('runNonInteractive', () => {
|
|||||||
|
|
||||||
await runNonInteractive(
|
await runNonInteractive(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
'Trigger tool not found',
|
'Trigger tool not found',
|
||||||
'prompt-id-5',
|
'prompt-id-5',
|
||||||
);
|
);
|
||||||
@@ -322,7 +379,12 @@ describe('runNonInteractive', () => {
|
|||||||
it('should exit when max session turns are exceeded', async () => {
|
it('should exit when max session turns are exceeded', async () => {
|
||||||
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
|
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
|
||||||
await expect(
|
await expect(
|
||||||
runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'),
|
runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Trigger loop',
|
||||||
|
'prompt-id-6',
|
||||||
|
),
|
||||||
).rejects.toThrow('process.exit(53) called');
|
).rejects.toThrow('process.exit(53) called');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -361,7 +423,7 @@ describe('runNonInteractive', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 4. Run the non-interactive mode with the raw input
|
// 4. Run the non-interactive mode with the raw input
|
||||||
await runNonInteractive(mockConfig, rawInput, 'prompt-id-7');
|
await runNonInteractive(mockConfig, mockSettings, rawInput, 'prompt-id-7');
|
||||||
|
|
||||||
// 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
|
// 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||||
@@ -408,7 +470,12 @@ describe('runNonInteractive', () => {
|
|||||||
};
|
};
|
||||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics);
|
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics);
|
||||||
|
|
||||||
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1');
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Test input',
|
||||||
|
'prompt-id-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||||
[{ text: 'Test input' }],
|
[{ text: 'Test input' }],
|
||||||
@@ -495,6 +562,7 @@ describe('runNonInteractive', () => {
|
|||||||
|
|
||||||
await runNonInteractive(
|
await runNonInteractive(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
'Execute tool only',
|
'Execute tool only',
|
||||||
'prompt-id-tool-only',
|
'prompt-id-tool-only',
|
||||||
);
|
);
|
||||||
@@ -548,6 +616,7 @@ describe('runNonInteractive', () => {
|
|||||||
|
|
||||||
await runNonInteractive(
|
await runNonInteractive(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
'Empty response test',
|
'Empty response test',
|
||||||
'prompt-id-empty',
|
'prompt-id-empty',
|
||||||
);
|
);
|
||||||
@@ -579,7 +648,12 @@ describe('runNonInteractive', () => {
|
|||||||
|
|
||||||
let thrownError: Error | null = null;
|
let thrownError: Error | null = null;
|
||||||
try {
|
try {
|
||||||
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-error');
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Test input',
|
||||||
|
'prompt-id-error',
|
||||||
|
);
|
||||||
// Should not reach here
|
// Should not reach here
|
||||||
expect.fail('Expected process.exit to be called');
|
expect.fail('Expected process.exit to be called');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -619,7 +693,12 @@ describe('runNonInteractive', () => {
|
|||||||
|
|
||||||
let thrownError: Error | null = null;
|
let thrownError: Error | null = null;
|
||||||
try {
|
try {
|
||||||
await runNonInteractive(mockConfig, 'Invalid syntax', 'prompt-id-fatal');
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Invalid syntax',
|
||||||
|
'prompt-id-fatal',
|
||||||
|
);
|
||||||
// Should not reach here
|
// Should not reach here
|
||||||
expect.fail('Expected process.exit to be called');
|
expect.fail('Expected process.exit to be called');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -643,4 +722,155 @@ describe('runNonInteractive', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should execute a slash command that returns a prompt', async () => {
|
||||||
|
const mockCommand = {
|
||||||
|
name: 'testcommand',
|
||||||
|
description: 'a test command',
|
||||||
|
action: vi.fn().mockResolvedValue({
|
||||||
|
type: 'submit_prompt',
|
||||||
|
content: [{ text: 'Prompt from command' }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
|
const events: ServerGeminiStreamEvent[] = [
|
||||||
|
{ type: GeminiEventType.Content, value: 'Response from command' },
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents(events),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'/testcommand',
|
||||||
|
'prompt-id-slash',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure the prompt sent to the model is from the command, not the raw input
|
||||||
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||||
|
[{ text: 'Prompt from command' }],
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
'prompt-id-slash',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw FatalInputError if a command requires confirmation', async () => {
|
||||||
|
const mockCommand = {
|
||||||
|
name: 'confirm',
|
||||||
|
description: 'a command that needs confirmation',
|
||||||
|
action: vi.fn().mockResolvedValue({
|
||||||
|
type: 'confirm_shell_commands',
|
||||||
|
commands: ['rm -rf /'],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'/confirm',
|
||||||
|
'prompt-id-confirm',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'Exiting due to a confirmation prompt requested by the command.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat an unknown slash command as a regular prompt', async () => {
|
||||||
|
// No commands are mocked, so any slash command is "unknown"
|
||||||
|
mockGetCommands.mockReturnValue([]);
|
||||||
|
|
||||||
|
const events: ServerGeminiStreamEvent[] = [
|
||||||
|
{ type: GeminiEventType.Content, value: 'Response to unknown' },
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents(events),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'/unknowncommand',
|
||||||
|
'prompt-id-unknown',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure the raw input is sent to the model
|
||||||
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||||
|
[{ text: '/unknowncommand' }],
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
'prompt-id-unknown',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for unhandled command result types', async () => {
|
||||||
|
const mockCommand = {
|
||||||
|
name: 'noaction',
|
||||||
|
description: 'unhandled type',
|
||||||
|
action: vi.fn().mockResolvedValue({
|
||||||
|
type: 'unhandled',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'/noaction',
|
||||||
|
'prompt-id-unhandled',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass arguments to the slash command action', async () => {
|
||||||
|
const mockAction = vi.fn().mockResolvedValue({
|
||||||
|
type: 'submit_prompt',
|
||||||
|
content: [{ text: 'Prompt from command' }],
|
||||||
|
});
|
||||||
|
const mockCommand = {
|
||||||
|
name: 'testargs',
|
||||||
|
description: 'a test command',
|
||||||
|
action: mockAction,
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
|
const events: ServerGeminiStreamEvent[] = [
|
||||||
|
{ type: GeminiEventType.Content, value: 'Acknowledged' },
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents(events),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'/testargs arg1 arg2',
|
||||||
|
'prompt-id-args',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');
|
||||||
|
|
||||||
|
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config, ToolCallRequestInfo } from '@google/gemini-cli-core';
|
import type { Config, ToolCallRequestInfo } from '@google/gemini-cli-core';
|
||||||
|
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||||
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
import {
|
import {
|
||||||
executeToolCall,
|
executeToolCall,
|
||||||
shutdownTelemetry,
|
shutdownTelemetry,
|
||||||
@@ -16,8 +18,10 @@ import {
|
|||||||
JsonFormatter,
|
JsonFormatter,
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
import type { Content, Part } from '@google/genai';
|
import type { Content, Part } from '@google/genai';
|
||||||
|
|
||||||
|
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +33,7 @@ import {
|
|||||||
|
|
||||||
export async function runNonInteractive(
|
export async function runNonInteractive(
|
||||||
config: Config,
|
config: Config,
|
||||||
|
settings: LoadedSettings,
|
||||||
input: string,
|
input: string,
|
||||||
prompt_id: string,
|
prompt_id: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -52,6 +57,24 @@ export async function runNonInteractive(
|
|||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
let query: Part[] | undefined;
|
||||||
|
|
||||||
|
if (isSlashCommand(input)) {
|
||||||
|
const slashCommandResult = await handleSlashCommand(
|
||||||
|
input,
|
||||||
|
abortController,
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
// If a slash command is found and returns a prompt, use it.
|
||||||
|
// Otherwise, slashCommandResult fall through to the default prompt
|
||||||
|
// handling.
|
||||||
|
if (slashCommandResult) {
|
||||||
|
query = slashCommandResult as Part[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
const { processedQuery, shouldProceed } = await handleAtCommand({
|
const { processedQuery, shouldProceed } = await handleAtCommand({
|
||||||
query: input,
|
query: input,
|
||||||
config,
|
config,
|
||||||
@@ -68,10 +91,10 @@ export async function runNonInteractive(
|
|||||||
'Exiting due to an error processing the @ command.',
|
'Exiting due to an error processing the @ command.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
query = processedQuery as Part[];
|
||||||
|
}
|
||||||
|
|
||||||
let currentMessages: Content[] = [
|
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||||
{ role: 'user', parts: processedQuery as Part[] },
|
|
||||||
];
|
|
||||||
|
|
||||||
let turnCount = 0;
|
let turnCount = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PartListUnion } from '@google/genai';
|
||||||
|
import { parseSlashCommand } from './utils/commands.js';
|
||||||
|
import {
|
||||||
|
FatalInputError,
|
||||||
|
Logger,
|
||||||
|
uiTelemetryService,
|
||||||
|
type Config,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { CommandService } from './services/CommandService.js';
|
||||||
|
import { FileCommandLoader } from './services/FileCommandLoader.js';
|
||||||
|
import type { CommandContext } from './ui/commands/types.js';
|
||||||
|
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||||
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
|
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a slash command in a non-interactive environment.
|
||||||
|
*
|
||||||
|
* @returns A Promise that resolves to `PartListUnion` if a valid command is
|
||||||
|
* found and results in a prompt, or `undefined` otherwise.
|
||||||
|
* @throws {FatalInputError} if the command result is not supported in
|
||||||
|
* non-interactive mode.
|
||||||
|
*/
|
||||||
|
export const handleSlashCommand = async (
|
||||||
|
rawQuery: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
config: Config,
|
||||||
|
settings: LoadedSettings,
|
||||||
|
): Promise<PartListUnion | undefined> => {
|
||||||
|
const trimmed = rawQuery.trim();
|
||||||
|
if (!trimmed.startsWith('/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only custom commands are supported for now.
|
||||||
|
const loaders = [new FileCommandLoader(config)];
|
||||||
|
const commandService = await CommandService.create(
|
||||||
|
loaders,
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
const commands = commandService.getCommands();
|
||||||
|
|
||||||
|
const { commandToExecute, args } = parseSlashCommand(rawQuery, commands);
|
||||||
|
|
||||||
|
if (commandToExecute) {
|
||||||
|
if (commandToExecute.action) {
|
||||||
|
// Not used by custom commands but may be in the future.
|
||||||
|
const sessionStats: SessionStatsState = {
|
||||||
|
sessionId: config?.getSessionId(),
|
||||||
|
sessionStartTime: new Date(),
|
||||||
|
metrics: uiTelemetryService.getMetrics(),
|
||||||
|
lastPromptTokenCount: 0,
|
||||||
|
promptCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
||||||
|
|
||||||
|
const context: CommandContext = {
|
||||||
|
services: {
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
git: undefined,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
ui: createNonInteractiveUI(),
|
||||||
|
session: {
|
||||||
|
stats: sessionStats,
|
||||||
|
sessionShellAllowlist: new Set(),
|
||||||
|
},
|
||||||
|
invocation: {
|
||||||
|
raw: trimmed,
|
||||||
|
name: commandToExecute.name,
|
||||||
|
args,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await commandToExecute.action(context, args);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
switch (result.type) {
|
||||||
|
case 'submit_prompt':
|
||||||
|
return result.content;
|
||||||
|
case 'confirm_shell_commands':
|
||||||
|
// This result indicates a command attempted to confirm shell commands.
|
||||||
|
// However note that currently, ShellTool is excluded in non-interactive
|
||||||
|
// mode unless 'YOLO mode' is active, so confirmation actually won't
|
||||||
|
// occur because of YOLO mode.
|
||||||
|
// This ensures that if a command *does* request confirmation (e.g.
|
||||||
|
// in the future with more granular permissions), it's handled appropriately.
|
||||||
|
throw new FatalInputError(
|
||||||
|
'Exiting due to a confirmation prompt requested by the command.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new FatalInputError(
|
||||||
|
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CommandContext } from '../commands/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a UI context object with no-op functions.
|
||||||
|
* Useful for non-interactive environments where UI operations
|
||||||
|
* are not applicable.
|
||||||
|
*/
|
||||||
|
export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||||
|
return {
|
||||||
|
addItem: (_item, _timestamp) => 0,
|
||||||
|
clear: () => {},
|
||||||
|
setDebugMessage: (_message) => {},
|
||||||
|
loadHistory: (_newHistory) => {},
|
||||||
|
pendingItem: null,
|
||||||
|
setPendingItem: (_item) => {},
|
||||||
|
toggleCorgiMode: () => {},
|
||||||
|
toggleVimEnabled: async () => false,
|
||||||
|
setGeminiMdFileCount: (_count) => {},
|
||||||
|
reloadCommands: () => {},
|
||||||
|
extensionsUpdateState: new Map(),
|
||||||
|
setExtensionsUpdateState: (_updateState) => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user