From 1a167b2ea5ef10d0bea66e227bd2148d4934f5b5 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Mon, 21 Apr 2025 17:41:44 -0700 Subject: [PATCH] Piped input (#104) * New method for handling stdin. Bypass Ink, and output to stdout. Makes the CLI work like a typical Unix application when called with piped input. * Fixing a few post-merge errors. * Format code. * Clean up lint and format errors. --- packages/cli/src/gemini.ts | 66 +++++++++++++++++++-- packages/cli/src/ui/App.tsx | 15 ++++- packages/cli/src/ui/hooks/useStdin.ts | 84 --------------------------- packages/cli/src/utils/readStdin.ts | 27 +++++++++ 4 files changed, 100 insertions(+), 92 deletions(-) delete mode 100644 packages/cli/src/ui/hooks/useStdin.ts create mode 100644 packages/cli/src/utils/readStdin.ts diff --git a/packages/cli/src/gemini.ts b/packages/cli/src/gemini.ts index 8df10aba..0d8b1ac7 100644 --- a/packages/cli/src/gemini.ts +++ b/packages/cli/src/gemini.ts @@ -8,16 +8,70 @@ import React from 'react'; import { render } from 'ink'; import { App } from './ui/App.js'; import { loadCliConfig } from './config/config.js'; +import { readStdin } from './utils/readStdin.js'; +import { GeminiClient, ServerTool } from '@gemini-code/server'; + +import { PartListUnion } from '@google/genai'; async function main() { + let initialInput: string | undefined = undefined; + + // Check if input is being piped + if (!process.stdin.isTTY) { + try { + initialInput = await readStdin(); + } catch (error) { + console.error('Error reading from stdin:', error); + process.exit(1); + } + } + // Load configuration const config = loadCliConfig(); - // Render UI, passing necessary config values - render( - React.createElement(App, { - config, - }), - ); + + // Render UI, passing necessary config values and initial input + if (process.stdin.isTTY) { + render( + React.createElement(App, { + config, + initialInput, + }), + ); + } else if (initialInput) { + // If not a TTY and we have initial input, process it directly + const geminiClient = new GeminiClient( + config.getApiKey(), + config.getModel(), + ); + const toolRegistry = config.getToolRegistry(); + const availableTools: ServerTool[] = toolRegistry.getAllTools(); + const toolDeclarations = toolRegistry.getFunctionDeclarations(); + const chat = await geminiClient.startChat(toolDeclarations); + + const request: PartListUnion = [{ text: initialInput }]; + + try { + for await (const event of geminiClient.sendMessageStream( + chat, + request, + availableTools, + )) { + if (event.type === 'content') { + process.stdout.write(event.value); + } + // We might need to handle other event types later, but for now, just content. + } + process.stdout.write('\n'); // Add a newline at the end + process.exit(0); + } catch (error) { + console.error('Error processing piped input:', error); + process.exit(1); + } + } else { + // If not a TTY and no initial input, exit with an error + console.error('No input provided via stdin.'); + process.exit(1); + } } // --- Global Unhandled Rejection Handler --- diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index f3e8b742..3bfb73db 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; // Added useEffect import { Box, Text } from 'ink'; import { StreamingState, type HistoryItem } from './types.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; @@ -25,9 +25,11 @@ import { Colors } from './colors.js'; interface AppProps { config: Config; + initialInput?: string; // Added optional prop } -export const App = ({ config }: AppProps) => { +export const App = ({ config, initialInput }: AppProps) => { + // Destructured prop const [history, setHistory] = useState([]); const [startupWarnings, setStartupWarnings] = useState([]); const { streamingState, submitQuery, initError, debugMessage } = @@ -38,6 +40,15 @@ export const App = ({ config }: AppProps) => { useStartupWarnings(setStartupWarnings); useInitializationErrorEffect(initError, history, setHistory); + // Effect to handle initial piped input + useEffect(() => { + if (initialInput && initialInput.trim() !== '') { + submitQuery(initialInput); + } + // Run only once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const userMessages = useMemo( () => history diff --git a/packages/cli/src/ui/hooks/useStdin.ts b/packages/cli/src/ui/hooks/useStdin.ts deleted file mode 100644 index dc245254..00000000 --- a/packages/cli/src/ui/hooks/useStdin.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useEffect } from 'react'; -import { useStdin } from 'ink'; - -export interface PipedInputState { - data: string | null; // Use null initially to distinguish from empty string - isLoading: boolean; - error: string | null; - isPiped: boolean; // Flag to indicate if input was piped -} - -export function usePipedInput(): PipedInputState { - const { stdin, setRawMode, isRawModeSupported } = useStdin(); - // Keep exit available if needed, e.g., for error handling, but maybe let consumer handle it - // const { exit } = useApp(); - - const [pipedData, setPipedData] = useState(null); - const [isLoading, setIsLoading] = useState(true); // Assume loading until checked - const [error, setError] = useState(null); - const [isPiped, setIsPiped] = useState(false); - - useEffect(() => { - // Determine if input is piped ONLY ONCE - const checkIsPiped = !stdin || !stdin.isTTY; - setIsPiped(checkIsPiped); - - if (checkIsPiped) { - // Piped input detected - if (isRawModeSupported) { - setRawMode(false); // Ensure raw mode is off for stream reading - } - - // Ensure stdin is available (it should be if !isTTY) - if (!stdin) { - setError('Stdin stream is unavailable.'); - setIsLoading(false); - return; // Cannot proceed - } - - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - }; - - const handleError = (err: Error) => { - setError('Error reading from stdin: ' + err.message); - setIsLoading(false); - // Decide if the hook should trigger exit or just report the error - // exit(); - }; - - const handleEnd = () => { - setPipedData(data); - setIsLoading(false); - // Don't exit here, let the component using the hook decide - }; - - stdin.on('data', handleData); - stdin.on('error', handleError); - stdin.on('end', handleEnd); - - // Cleanup listeners - return () => { - stdin.removeListener('data', handleData); - stdin.removeListener('error', handleError); - stdin.removeListener('end', handleEnd); - }; - } - - // No piped input (running interactively) - setIsLoading(false); - // Optionally set an 'info' state or just let isLoading=false & isPiped=false suffice - // setError('No piped input detected.'); // Maybe don't treat this as an 'error' - - // Intentionally run only once on mount or when stdin theoretically changes - }, [stdin, isRawModeSupported, setRawMode /*, exit */]); - - return { data: pipedData, isLoading, error, isPiped }; -} diff --git a/packages/cli/src/utils/readStdin.ts b/packages/cli/src/utils/readStdin.ts new file mode 100644 index 00000000..d890aa2c --- /dev/null +++ b/packages/cli/src/utils/readStdin.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + + process.stdin.on('readable', () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + data += chunk; + } + }); + + process.stdin.on('end', () => { + resolve(data); + }); + + process.stdin.on('error', (err) => { + reject(err); + }); + }); +}