summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/shellCommandProcessor.ts
diff options
context:
space:
mode:
authorAbhi <[email protected]>2025-07-25 21:56:49 -0400
committerGitHub <[email protected]>2025-07-26 01:56:49 +0000
commitca5dd28ab60d78f42150460cbe9d4ed58d40afe4 (patch)
tree6f3b3b373133a6c0bf8bb9cee8a10cc06ac83c9a /packages/cli/src/ui/hooks/shellCommandProcessor.ts
parentad2ef080aae2e21bf04ad8e922719ceaa81f1e5f (diff)
refactor(core): Centralize shell logic into ShellExecutionService (#4823)
Diffstat (limited to 'packages/cli/src/ui/hooks/shellCommandProcessor.ts')
-rw-r--r--packages/cli/src/ui/hooks/shellCommandProcessor.ts431
1 files changed, 174 insertions, 257 deletions
diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts
index 9e343f90..08df0a74 100644
--- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts
@@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { spawn } from 'child_process';
-import { TextDecoder } from 'util';
import {
HistoryItemWithoutId,
IndividualToolCallDisplay,
@@ -15,186 +13,22 @@ import { useCallback } from 'react';
import {
Config,
GeminiClient,
- getCachedEncodingForBuffer,
+ isBinary,
+ ShellExecutionResult,
+ ShellExecutionService,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
-import { formatMemoryUsage } from '../utils/formatters.js';
-import { isBinary } from '../utils/textUtils.js';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
+import { formatMemoryUsage } from '../utils/formatters.js';
import crypto from 'crypto';
import path from 'path';
import os from 'os';
import fs from 'fs';
-import stripAnsi from 'strip-ansi';
-const OUTPUT_UPDATE_INTERVAL_MS = 1000;
+export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const MAX_OUTPUT_LENGTH = 10000;
-/**
- * A structured result from a shell command execution.
- */
-interface ShellExecutionResult {
- rawOutput: Buffer;
- output: string;
- exitCode: number | null;
- signal: NodeJS.Signals | null;
- error: Error | null;
- aborted: boolean;
-}
-
-/**
- * Executes a shell command using `spawn`, capturing all output and lifecycle events.
- * This is the single, unified implementation for shell execution.
- *
- * @param commandToExecute The exact command string to run.
- * @param cwd The working directory to execute the command in.
- * @param abortSignal An AbortSignal to terminate the process.
- * @param onOutputChunk A callback for streaming real-time output.
- * @param onDebugMessage A callback for logging debug information.
- * @returns A promise that resolves with the complete execution result.
- */
-function executeShellCommand(
- commandToExecute: string,
- cwd: string,
- abortSignal: AbortSignal,
- onOutputChunk: (chunk: string) => void,
- onDebugMessage: (message: string) => void,
-): Promise<ShellExecutionResult> {
- return new Promise((resolve) => {
- const isWindows = os.platform() === 'win32';
- const shell = isWindows ? 'cmd.exe' : 'bash';
- const shellArgs = isWindows
- ? ['/c', commandToExecute]
- : ['-c', commandToExecute];
-
- const child = spawn(shell, shellArgs, {
- cwd,
- stdio: ['ignore', 'pipe', 'pipe'],
- detached: !isWindows, // Use process groups on non-Windows for robust killing
- env: {
- ...process.env,
- GEMINI_CLI: '1',
- },
- });
-
- // Use decoders to handle multi-byte characters safely (for streaming output).
- let stdoutDecoder: TextDecoder | null = null;
- let stderrDecoder: TextDecoder | null = null;
-
- let stdout = '';
- let stderr = '';
- const outputChunks: Buffer[] = [];
- let error: Error | null = null;
- let exited = false;
-
- let streamToUi = true;
- const MAX_SNIFF_SIZE = 4096;
- let sniffedBytes = 0;
-
- const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
- if (!stdoutDecoder || !stderrDecoder) {
- const encoding = getCachedEncodingForBuffer(data);
- stdoutDecoder = new TextDecoder(encoding);
- stderrDecoder = new TextDecoder(encoding);
- }
-
- outputChunks.push(data);
-
- if (streamToUi && sniffedBytes < MAX_SNIFF_SIZE) {
- // Use a limited-size buffer for the check to avoid performance issues.
- const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
- sniffedBytes = sniffBuffer.length;
-
- if (isBinary(sniffBuffer)) {
- streamToUi = false;
- // Overwrite any garbled text that may have streamed with a clear message.
- onOutputChunk('[Binary output detected. Halting stream...]');
- }
- }
-
- const decodedChunk =
- stream === 'stdout'
- ? stdoutDecoder.decode(data, { stream: true })
- : stderrDecoder.decode(data, { stream: true });
- if (stream === 'stdout') {
- stdout += stripAnsi(decodedChunk);
- } else {
- stderr += stripAnsi(decodedChunk);
- }
-
- if (!exited && streamToUi) {
- // Send only the new chunk to avoid re-rendering the whole output.
- const combinedOutput = stdout + (stderr ? `\n${stderr}` : '');
- onOutputChunk(combinedOutput);
- } else if (!exited && !streamToUi) {
- // Send progress updates for the binary stream
- const totalBytes = outputChunks.reduce(
- (sum, chunk) => sum + chunk.length,
- 0,
- );
- onOutputChunk(
- `[Receiving binary output... ${formatMemoryUsage(totalBytes)} received]`,
- );
- }
- };
-
- child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
- child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
- child.on('error', (err) => {
- error = err;
- });
-
- const abortHandler = async () => {
- if (child.pid && !exited) {
- onDebugMessage(`Aborting shell command (PID: ${child.pid})`);
- if (isWindows) {
- spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
- } else {
- try {
- // Kill the entire process group (negative PID).
- // SIGTERM first, then SIGKILL if it doesn't die.
- process.kill(-child.pid, 'SIGTERM');
- await new Promise((res) => setTimeout(res, 200));
- if (!exited) {
- process.kill(-child.pid, 'SIGKILL');
- }
- } catch (_e) {
- // Fall back to killing just the main process if group kill fails.
- if (!exited) child.kill('SIGKILL');
- }
- }
- }
- };
-
- abortSignal.addEventListener('abort', abortHandler, { once: true });
-
- child.on('exit', (code, signal) => {
- exited = true;
- abortSignal.removeEventListener('abort', abortHandler);
-
- // Handle any final bytes lingering in the decoders
- if (stdoutDecoder) {
- stdout += stdoutDecoder.decode();
- }
- if (stderrDecoder) {
- stderr += stderrDecoder.decode();
- }
-
- const finalBuffer = Buffer.concat(outputChunks);
-
- resolve({
- rawOutput: finalBuffer,
- output: stdout + (stderr ? `\n${stderr}` : ''),
- exitCode: code,
- signal,
- error,
- aborted: abortSignal.aborted,
- });
- });
- });
-}
-
function addShellCommandToGeminiHistory(
geminiClient: GeminiClient,
rawQuery: string,
@@ -227,7 +61,6 @@ ${modelContent}
* Hook to process shell commands.
* Orchestrates command execution and updates history and agent context.
*/
-
export const useShellCommandProcessor = (
addItemToHistory: UseHistoryManagerReturn['addItem'],
setPendingHistoryItem: React.Dispatch<
@@ -269,7 +102,11 @@ export const useShellCommandProcessor = (
}
const execPromise = new Promise<void>((resolve) => {
- let lastUpdateTime = 0;
+ let lastUpdateTime = Date.now();
+ let cumulativeStdout = '';
+ let cumulativeStderr = '';
+ let isBinaryStream = false;
+ let binaryBytesReceived = 0;
const initialToolDisplay: IndividualToolCallDisplay = {
callId,
@@ -285,103 +122,183 @@ export const useShellCommandProcessor = (
tools: [initialToolDisplay],
});
+ let executionPid: number | undefined;
+
+ const abortHandler = () => {
+ onDebugMessage(
+ `Aborting shell command (PID: ${executionPid ?? 'unknown'})`,
+ );
+ };
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
+
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
- executeShellCommand(
- commandToExecute,
- targetDir,
- abortSignal,
- (streamedOutput) => {
- // Throttle pending UI updates to avoid excessive re-renders.
- if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
- setPendingHistoryItem({
- type: 'tool_group',
- tools: [
- { ...initialToolDisplay, resultDisplay: streamedOutput },
- ],
- });
- lastUpdateTime = Date.now();
- }
- },
- onDebugMessage,
- )
- .then((result) => {
- setPendingHistoryItem(null);
- let mainContent: string;
+ try {
+ const { pid, result } = ShellExecutionService.execute(
+ commandToExecute,
+ targetDir,
+ (event) => {
+ switch (event.type) {
+ case 'data':
+ // Do not process text data if we've already switched to binary mode.
+ if (isBinaryStream) break;
+ if (event.stream === 'stdout') {
+ cumulativeStdout += event.chunk;
+ } else {
+ cumulativeStderr += event.chunk;
+ }
+ break;
+ case 'binary_detected':
+ isBinaryStream = true;
+ break;
+ case 'binary_progress':
+ isBinaryStream = true;
+ binaryBytesReceived = event.bytesReceived;
+ break;
+ default: {
+ throw new Error('An unhandled ShellOutputEvent was found.');
+ }
+ }
+
+ // Compute the display string based on the *current* state.
+ let currentDisplayOutput: string;
+ if (isBinaryStream) {
+ if (binaryBytesReceived > 0) {
+ currentDisplayOutput = `[Receiving binary output... ${formatMemoryUsage(
+ binaryBytesReceived,
+ )} received]`;
+ } else {
+ currentDisplayOutput =
+ '[Binary output detected. Halting stream...]';
+ }
+ } else {
+ currentDisplayOutput =
+ cumulativeStdout +
+ (cumulativeStderr ? `\n${cumulativeStderr}` : '');
+ }
+
+ // Throttle pending UI updates to avoid excessive re-renders.
+ if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
+ setPendingHistoryItem({
+ type: 'tool_group',
+ tools: [
+ {
+ ...initialToolDisplay,
+ resultDisplay: currentDisplayOutput,
+ },
+ ],
+ });
+ lastUpdateTime = Date.now();
+ }
+ },
+ abortSignal,
+ );
- if (isBinary(result.rawOutput)) {
- mainContent =
- '[Command produced binary output, which is not shown.]';
- } else {
- mainContent =
- result.output.trim() || '(Command produced no output)';
- }
+ executionPid = pid;
- let finalOutput = mainContent;
- let finalStatus = ToolCallStatus.Success;
+ result
+ .then((result: ShellExecutionResult) => {
+ setPendingHistoryItem(null);
- if (result.error) {
- finalStatus = ToolCallStatus.Error;
- finalOutput = `${result.error.message}\n${finalOutput}`;
- } else if (result.aborted) {
- finalStatus = ToolCallStatus.Canceled;
- finalOutput = `Command was cancelled.\n${finalOutput}`;
- } else if (result.signal) {
- finalStatus = ToolCallStatus.Error;
- finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
- } else if (result.exitCode !== 0) {
- finalStatus = ToolCallStatus.Error;
- finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
- }
+ let mainContent: string;
- if (pwdFilePath && fs.existsSync(pwdFilePath)) {
- const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
- if (finalPwd && finalPwd !== targetDir) {
- const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
- finalOutput = `${warning}\n\n${finalOutput}`;
+ if (isBinary(result.rawOutput)) {
+ mainContent =
+ '[Command produced binary output, which is not shown.]';
+ } else {
+ mainContent =
+ result.output.trim() || '(Command produced no output)';
}
- }
- const finalToolDisplay: IndividualToolCallDisplay = {
- ...initialToolDisplay,
- status: finalStatus,
- resultDisplay: finalOutput,
- };
+ let finalOutput = mainContent;
+ let finalStatus = ToolCallStatus.Success;
+
+ if (result.error) {
+ finalStatus = ToolCallStatus.Error;
+ finalOutput = `${result.error.message}\n${finalOutput}`;
+ } else if (result.aborted) {
+ finalStatus = ToolCallStatus.Canceled;
+ finalOutput = `Command was cancelled.\n${finalOutput}`;
+ } else if (result.signal) {
+ finalStatus = ToolCallStatus.Error;
+ finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
+ } else if (result.exitCode !== 0) {
+ finalStatus = ToolCallStatus.Error;
+ finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
+ }
- // Add the complete, contextual result to the local UI history.
- addItemToHistory(
- {
- type: 'tool_group',
- tools: [finalToolDisplay],
- } as HistoryItemWithoutId,
- userMessageTimestamp,
- );
+ if (pwdFilePath && fs.existsSync(pwdFilePath)) {
+ const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
+ if (finalPwd && finalPwd !== targetDir) {
+ const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
+ finalOutput = `${warning}\n\n${finalOutput}`;
+ }
+ }
- // Add the same complete, contextual result to the LLM's history.
- addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
- })
- .catch((err) => {
- setPendingHistoryItem(null);
- const errorMessage =
- err instanceof Error ? err.message : String(err);
- addItemToHistory(
- {
- type: 'error',
- text: `An unexpected error occurred: ${errorMessage}`,
- },
- userMessageTimestamp,
- );
- })
- .finally(() => {
- if (pwdFilePath && fs.existsSync(pwdFilePath)) {
- fs.unlinkSync(pwdFilePath);
- }
- resolve();
- });
+ const finalToolDisplay: IndividualToolCallDisplay = {
+ ...initialToolDisplay,
+ status: finalStatus,
+ resultDisplay: finalOutput,
+ };
+
+ // Add the complete, contextual result to the local UI history.
+ addItemToHistory(
+ {
+ type: 'tool_group',
+ tools: [finalToolDisplay],
+ } as HistoryItemWithoutId,
+ userMessageTimestamp,
+ );
+
+ // Add the same complete, contextual result to the LLM's history.
+ addShellCommandToGeminiHistory(
+ geminiClient,
+ rawQuery,
+ finalOutput,
+ );
+ })
+ .catch((err) => {
+ setPendingHistoryItem(null);
+ const errorMessage =
+ err instanceof Error ? err.message : String(err);
+ addItemToHistory(
+ {
+ type: 'error',
+ text: `An unexpected error occurred: ${errorMessage}`,
+ },
+ userMessageTimestamp,
+ );
+ })
+ .finally(() => {
+ abortSignal.removeEventListener('abort', abortHandler);
+ if (pwdFilePath && fs.existsSync(pwdFilePath)) {
+ fs.unlinkSync(pwdFilePath);
+ }
+ resolve();
+ });
+ } catch (err) {
+ // This block handles synchronous errors from `execute`
+ setPendingHistoryItem(null);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ addItemToHistory(
+ {
+ type: 'error',
+ text: `An unexpected error occurred: ${errorMessage}`,
+ },
+ userMessageTimestamp,
+ );
+
+ // Perform cleanup here as well
+ if (pwdFilePath && fs.existsSync(pwdFilePath)) {
+ fs.unlinkSync(pwdFilePath);
+ }
+
+ resolve(); // Resolve the promise to unblock `onExec`
+ }
});
onExec(execPromise);
- return true; // Command was initiated
+ return true;
},
[
config,