From 90de934273ba3f2ec8803f62c941a98ddcc97e8c Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 17 Jun 2025 12:11:18 -0700 Subject: [PATCH 1/4] demo: gemini integration --- src/deploy/index.ts | 30 +++++- src/gemini/chat.ts | 218 +++++++++++++++++++++++++++++++++++++++++++ src/gemini/logger.ts | 35 +++++++ 3 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 src/gemini/chat.ts create mode 100644 src/gemini/logger.ts diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 7dd2d093a2b..f8bc66d066f 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -26,6 +26,9 @@ import { TARGET_PERMISSIONS } from "../commands/deploy"; import { requirePermissions } from "../requirePermissions"; import { Options } from "../options"; import { HostingConfig } from "../firebaseConfig"; +import { confirm } from "../prompt"; +import { startChat } from "../gemini/chat"; +import { attachMemoryLogger, getLogs } from "../gemini/logger"; const TARGETS = { hosting: HostingTarget, @@ -148,11 +151,28 @@ export const deploy = async function ( logBullet("deploying " + bold(targetNames.join(", "))); - await chain(predeploys, context, options, payload); - await chain(prepares, context, options, payload); - await chain(deploys, context, options, payload); - await chain(releases, context, options, payload); - await chain(postdeploys, context, options, payload); + attachMemoryLogger(); + try { + await chain(predeploys, context, options, payload); + await chain(prepares, context, options, payload); + await chain(deploys, context, options, payload); + await chain(releases, context, options, payload); + await chain(postdeploys, context, options, payload); + } catch (err: any) { + if (process.env.GEMINI_API_KEY) { + const choice = await confirm({ + message: + "Deployment failed. Would you like to start a Gemini chat session to help debug?", + default: true, + }); + if (choice) { + const logs = getLogs(); + await startChat(err, logs); + return { hosting: undefined }; + } + } + throw err; + } const duration = Date.now() - startTime; const analyticsParams: AnalyticsParams = { diff --git a/src/gemini/chat.ts b/src/gemini/chat.ts new file mode 100644 index 00000000000..a122ea6bc03 --- /dev/null +++ b/src/gemini/chat.ts @@ -0,0 +1,218 @@ + +import { + Config, + sessionId, + DEFAULT_GEMINI_MODEL, + CoreToolScheduler, + GeminiChat, + ToolCallRequestInfo, + CompletedToolCall, + ToolCall, + LSTool, + ReadFileTool, + ShellTool, + GeminiClient, + ToolConfirmationOutcome, + WaitingToolCall, + WriteFileTool, + EditTool, +} from '@gemini-cli/core'; +import { Part } from '@google/genai'; +import * as readline from 'node:readline'; +import { logger } from '../logger'; +import { confirm } from '../prompt'; +import * as clc from "colorette"; +import Table from "cli-table3"; +import { diffLines } from "diff"; +import ora from "ora"; + +class InteractiveConversation { + private scheduler!: CoreToolScheduler; + private chat!: GeminiChat; + private rl: readline.Interface; + private isModelTurn = false; + private conversationFinished: Promise; + private resolveConversationFinished!: () => void; + + constructor(private config: Config) { + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: '> ', + }); + this.conversationFinished = new Promise((resolve) => { + this.resolveConversationFinished = resolve; + }); + } + + async initialize() { + const geminiClient = new GeminiClient(this.config); + this.chat = await geminiClient.getChat(); + + this.scheduler = new CoreToolScheduler({ + config: this.config, + toolRegistry: this.config.getToolRegistry(), + onAllToolCallsComplete: this.handleAllToolCallsComplete.bind(this), + onToolCallsUpdate: this.handleToolCallsUpdate.bind(this), + getPreferredEditor: () => undefined, + }); + + this.rl.on('line', (line) => { + if (this.isModelTurn) return; + if (line.trim().toLowerCase() === 'exit') { + this.rl.close(); + return; + } + this.run(line); + }); + + this.rl.on('close', () => { + console.log('\nGoodbye!'); + this.resolveConversationFinished(); + }); + } + + private spinner: ora.Ora | undefined; + + private async handleToolCallsUpdate(toolCalls: ToolCall[]) { + for (const toolCall of toolCalls) { + if (toolCall.status === 'awaiting_approval') { + const waitingToolCall = toolCall as WaitingToolCall; + + if (waitingToolCall.request.name === "edit_file" || waitingToolCall.request.name === "write_file") { + const table = new Table({ + head: [clc.bold("File"), clc.bold("Changes")], + style: { head: [], border: [] } + }); + const args = waitingToolCall.request.args as { path: string, old?: string, new: string }; + const diff = diffLines(args.old || "", args.new); + let diffText = ""; + for (const part of diff) { + if (part.added) { + diffText += clc.green(part.value); + } else if (part.removed) { + diffText += clc.red(part.value); + } else { + diffText += clc.gray(part.value); + } + } + table.push([args.path, diffText]); + logger.info(`\n${table.toString()}`); + } else { + logger.info(`[THINKING] Model wants to call ${waitingToolCall.request.name}(${JSON.stringify(waitingToolCall.request.args)})`); + } + + if (waitingToolCall.confirmationDetails) { + const { onConfirm } = waitingToolCall.confirmationDetails; + const message = `The model wants to run the tool: ${waitingToolCall.request.name}. Do you want to proceed?`; + + this.rl.pause(); + const proceed = await confirm({ message, default: true }); + this.rl.resume(); + + if (proceed) { + onConfirm(ToolConfirmationOutcome.ProceedOnce); + } else { + onConfirm(ToolConfirmationOutcome.Cancel); + } + } + } + if (toolCall.status === 'executing') { + this.spinner = ora(`[EXECUTING] Calling tool: ${toolCall.request.name}`).start(); + } + } + } + + private async handleAllToolCallsComplete(completedCalls: CompletedToolCall[]) { + if (this.spinner) { + this.spinner.stop(); + } + logger.info(`\n[RESULT] All tools finished executing.`); + logger.info('[THINKING] Sending results back to the model...\n'); + + const responseParts: Part[] = completedCalls.flatMap( + (call) => call.response.responseParts, + ) as Part[]; + await this.run(responseParts); + } + + start(initialMessage: string): Promise { + console.log('Interactive chat started. Type "exit" to quit.'); + this.run(initialMessage); + return this.conversationFinished; + } + + async run(message: string | Part[]) { + this.isModelTurn = true; + const abortController = new AbortController(); + const stream = await this.chat.sendMessageStream({ + message, + config: { + abortSignal: abortController.signal, + tools: [{ functionDeclarations: (await this.config.getToolRegistry()).getFunctionDeclarations() }], + }, + }); + + const toolCallRequests: ToolCallRequestInfo[] = []; + let finalResponse = ''; + + for await (const event of stream) { + if (event.functionCalls) { + toolCallRequests.push( + ...event.functionCalls.map( + (fc) => + ({ + callId: fc.id ?? `${fc.name}-${Date.now()}`, + name: fc.name, + args: fc.args, + }) as ToolCallRequestInfo, + ), + ); + } + if (event.candidates?.[0]?.content?.parts) { + for (const part of event.candidates[0].content.parts) { + if (part.text) { + process.stdout.write(part.text); + finalResponse += part.text; + } + } + } + } + + if (toolCallRequests.length > 0) { + this.scheduler.schedule(toolCallRequests, abortController.signal); + } else { + console.log(); + this.isModelTurn = false; + this.rl.prompt(); + } + } +} + +export async function startChat(error: Error, logs?: string[]) { + if (!process.env.GEMINI_API_KEY) { + throw new Error('GEMINI_API_KEY environment variable is not set.'); + } + + const config = new Config({ + sessionId, + targetDir: process.cwd(), + cwd: process.cwd(), + debugMode: false, + contentGeneratorConfig: { + apiKey: process.env.GEMINI_API_KEY, + model: DEFAULT_GEMINI_MODEL, + }, + coreTools: [LSTool.Name, ReadFileTool.Name, ShellTool.Name, WriteFileTool.Name, EditTool.Name], + }); + + const conversation = new InteractiveConversation(config); + await conversation.initialize(); + let initialMessage = `I encountered the following error during deployment: ${error.message}.`; + if (logs) { + initialMessage += `\n\nHere are the deployment logs:\n${logs.join("\n")}`; + } + initialMessage += "\n\nCan you help me debug it? Note: When using shell commands, please run them in the foreground to avoid issues with process tracking."; + await conversation.start(initialMessage); +} + diff --git a/src/gemini/logger.ts b/src/gemini/logger.ts new file mode 100644 index 00000000000..1a45e775311 --- /dev/null +++ b/src/gemini/logger.ts @@ -0,0 +1,35 @@ + +import * as Transport from "winston-transport"; +import { SPLAT } from "triple-beam"; +import { stripVTControlCharacters } from "util"; +import { logger } from "../logger"; + +export class MemoryLogger extends Transport { + logs: string[] = []; + + log(info: any, callback: () => void) { + const segments = [info.message, ...(info[SPLAT] || [])].map((v) => { + if (typeof v === "string") { + return v; + } + try { + return JSON.stringify(v); + } catch (e) { + return v; + } + }); + this.logs.push(stripVTControlCharacters(segments.join(" "))); + callback(); + } +} + +let memoryLogger: MemoryLogger | undefined; + +export function attachMemoryLogger() { + memoryLogger = new MemoryLogger(); + logger.add(memoryLogger); +} + +export function getLogs(): string[] { + return memoryLogger ? memoryLogger.logs : []; +} From 82cc8f5cc5c182b2c1d2705cc6d4d566dd8225cf Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Thu, 26 Jun 2025 10:11:13 -0700 Subject: [PATCH 2/4] refactor: use gemini cli directly. --- npm-shrinkwrap.json | 142 +++++++++++++++++++++++++++++ package.json | 1 + src/deploy/index.ts | 36 +++++--- src/gemini/chat.ts | 218 -------------------------------------------- src/gemini/cli.ts | 196 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 360 insertions(+), 233 deletions(-) delete mode 100644 src/gemini/chat.ts create mode 100644 src/gemini/cli.ts diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 3d8e5f3f6af..f350f2b1018 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -13,6 +13,7 @@ "@google-cloud/cloud-sql-connector": "^1.3.3", "@google-cloud/pubsub": "^4.5.0", "@inquirer/prompts": "^7.4.0", + "@lydell/node-pty": "^1.1.0", "@modelcontextprotocol/sdk": "^1.10.2", "abort-controller": "^3.0.0", "ajv": "^8.17.1", @@ -3652,6 +3653,98 @@ "integrity": "sha512-4/RWEeXDO6bocPONheFe6gX/oQdP/bEpv0oL4HqjPP5DCenBSt0mHgahppY49N0CpsaqffdwPq+TlX9CYOq2Dw==", "dev": true }, + "node_modules/@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz", @@ -24470,6 +24563,55 @@ "integrity": "sha512-4/RWEeXDO6bocPONheFe6gX/oQdP/bEpv0oL4HqjPP5DCenBSt0mHgahppY49N0CpsaqffdwPq+TlX9CYOq2Dw==", "dev": true }, + "@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "requires": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "optional": true + }, + "@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "optional": true + }, + "@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "optional": true + }, + "@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "optional": true + }, + "@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "optional": true + }, + "@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "optional": true + }, "@modelcontextprotocol/sdk": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz", diff --git a/package.json b/package.json index 62f9df89c55..087412d097d 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "@google-cloud/cloud-sql-connector": "^1.3.3", "@google-cloud/pubsub": "^4.5.0", "@inquirer/prompts": "^7.4.0", + "@lydell/node-pty": "^1.1.0", "@modelcontextprotocol/sdk": "^1.10.2", "abort-controller": "^3.0.0", "ajv": "^8.17.1", diff --git a/src/deploy/index.ts b/src/deploy/index.ts index f8bc66d066f..4de1a4f27d2 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -5,7 +5,9 @@ import { bold, underline, white } from "colorette"; import { includes, each } from "lodash"; import { needProjectId } from "../projectUtils"; import { logBullet, logSuccess, consoleUrl, addSubdomain } from "../utils"; +import { logError } from "../logError"; import { FirebaseError } from "../error"; +import { execSync } from "child_process"; import { AnalyticsParams, trackGA4 } from "../track"; import { lifecycleHooks } from "./lifecycleHooks"; import * as experiments from "../experiments"; @@ -27,7 +29,7 @@ import { requirePermissions } from "../requirePermissions"; import { Options } from "../options"; import { HostingConfig } from "../firebaseConfig"; import { confirm } from "../prompt"; -import { startChat } from "../gemini/chat"; +import { promptAndLaunchGemini } from "../gemini/cli"; import { attachMemoryLogger, getLogs } from "../gemini/logger"; const TARGETS = { @@ -89,7 +91,7 @@ export const deploy = async function ( targetNames: (keyof typeof TARGETS)[], options: DeployOptions, customContext = {}, -) { +): Promise<{ hosting: string | string[] | undefined }> { const projectId = needProjectId(options); const payload = {}; // a shared context object for deploy targets to decorate as needed @@ -159,19 +161,23 @@ export const deploy = async function ( await chain(releases, context, options, payload); await chain(postdeploys, context, options, payload); } catch (err: any) { - if (process.env.GEMINI_API_KEY) { - const choice = await confirm({ - message: - "Deployment failed. Would you like to start a Gemini chat session to help debug?", - default: true, - }); - if (choice) { - const logs = getLogs(); - await startChat(err, logs); - return { hosting: undefined }; - } - } - throw err; + logError(err); + + const logs = getLogs(); + const failedTargets = targetNames.join(", "); + const prompt = `I encountered an error during a Firebase deployment for the following services: ${failedTargets}. +Error: ${err.message} + +Here are the deployment logs: +${logs.join("\n")} + +Can you help me debug this deployment failure? Note: When using shell commands, please run them in the foreground to avoid issues with process tracking.`; + + await promptAndLaunchGemini(options.cwd || process.cwd(), prompt, () => { + return deploy(targetNames, options, customContext); + }); + + return { hosting: undefined }; } const duration = Date.now() - startTime; diff --git a/src/gemini/chat.ts b/src/gemini/chat.ts deleted file mode 100644 index a122ea6bc03..00000000000 --- a/src/gemini/chat.ts +++ /dev/null @@ -1,218 +0,0 @@ - -import { - Config, - sessionId, - DEFAULT_GEMINI_MODEL, - CoreToolScheduler, - GeminiChat, - ToolCallRequestInfo, - CompletedToolCall, - ToolCall, - LSTool, - ReadFileTool, - ShellTool, - GeminiClient, - ToolConfirmationOutcome, - WaitingToolCall, - WriteFileTool, - EditTool, -} from '@gemini-cli/core'; -import { Part } from '@google/genai'; -import * as readline from 'node:readline'; -import { logger } from '../logger'; -import { confirm } from '../prompt'; -import * as clc from "colorette"; -import Table from "cli-table3"; -import { diffLines } from "diff"; -import ora from "ora"; - -class InteractiveConversation { - private scheduler!: CoreToolScheduler; - private chat!: GeminiChat; - private rl: readline.Interface; - private isModelTurn = false; - private conversationFinished: Promise; - private resolveConversationFinished!: () => void; - - constructor(private config: Config) { - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - prompt: '> ', - }); - this.conversationFinished = new Promise((resolve) => { - this.resolveConversationFinished = resolve; - }); - } - - async initialize() { - const geminiClient = new GeminiClient(this.config); - this.chat = await geminiClient.getChat(); - - this.scheduler = new CoreToolScheduler({ - config: this.config, - toolRegistry: this.config.getToolRegistry(), - onAllToolCallsComplete: this.handleAllToolCallsComplete.bind(this), - onToolCallsUpdate: this.handleToolCallsUpdate.bind(this), - getPreferredEditor: () => undefined, - }); - - this.rl.on('line', (line) => { - if (this.isModelTurn) return; - if (line.trim().toLowerCase() === 'exit') { - this.rl.close(); - return; - } - this.run(line); - }); - - this.rl.on('close', () => { - console.log('\nGoodbye!'); - this.resolveConversationFinished(); - }); - } - - private spinner: ora.Ora | undefined; - - private async handleToolCallsUpdate(toolCalls: ToolCall[]) { - for (const toolCall of toolCalls) { - if (toolCall.status === 'awaiting_approval') { - const waitingToolCall = toolCall as WaitingToolCall; - - if (waitingToolCall.request.name === "edit_file" || waitingToolCall.request.name === "write_file") { - const table = new Table({ - head: [clc.bold("File"), clc.bold("Changes")], - style: { head: [], border: [] } - }); - const args = waitingToolCall.request.args as { path: string, old?: string, new: string }; - const diff = diffLines(args.old || "", args.new); - let diffText = ""; - for (const part of diff) { - if (part.added) { - diffText += clc.green(part.value); - } else if (part.removed) { - diffText += clc.red(part.value); - } else { - diffText += clc.gray(part.value); - } - } - table.push([args.path, diffText]); - logger.info(`\n${table.toString()}`); - } else { - logger.info(`[THINKING] Model wants to call ${waitingToolCall.request.name}(${JSON.stringify(waitingToolCall.request.args)})`); - } - - if (waitingToolCall.confirmationDetails) { - const { onConfirm } = waitingToolCall.confirmationDetails; - const message = `The model wants to run the tool: ${waitingToolCall.request.name}. Do you want to proceed?`; - - this.rl.pause(); - const proceed = await confirm({ message, default: true }); - this.rl.resume(); - - if (proceed) { - onConfirm(ToolConfirmationOutcome.ProceedOnce); - } else { - onConfirm(ToolConfirmationOutcome.Cancel); - } - } - } - if (toolCall.status === 'executing') { - this.spinner = ora(`[EXECUTING] Calling tool: ${toolCall.request.name}`).start(); - } - } - } - - private async handleAllToolCallsComplete(completedCalls: CompletedToolCall[]) { - if (this.spinner) { - this.spinner.stop(); - } - logger.info(`\n[RESULT] All tools finished executing.`); - logger.info('[THINKING] Sending results back to the model...\n'); - - const responseParts: Part[] = completedCalls.flatMap( - (call) => call.response.responseParts, - ) as Part[]; - await this.run(responseParts); - } - - start(initialMessage: string): Promise { - console.log('Interactive chat started. Type "exit" to quit.'); - this.run(initialMessage); - return this.conversationFinished; - } - - async run(message: string | Part[]) { - this.isModelTurn = true; - const abortController = new AbortController(); - const stream = await this.chat.sendMessageStream({ - message, - config: { - abortSignal: abortController.signal, - tools: [{ functionDeclarations: (await this.config.getToolRegistry()).getFunctionDeclarations() }], - }, - }); - - const toolCallRequests: ToolCallRequestInfo[] = []; - let finalResponse = ''; - - for await (const event of stream) { - if (event.functionCalls) { - toolCallRequests.push( - ...event.functionCalls.map( - (fc) => - ({ - callId: fc.id ?? `${fc.name}-${Date.now()}`, - name: fc.name, - args: fc.args, - }) as ToolCallRequestInfo, - ), - ); - } - if (event.candidates?.[0]?.content?.parts) { - for (const part of event.candidates[0].content.parts) { - if (part.text) { - process.stdout.write(part.text); - finalResponse += part.text; - } - } - } - } - - if (toolCallRequests.length > 0) { - this.scheduler.schedule(toolCallRequests, abortController.signal); - } else { - console.log(); - this.isModelTurn = false; - this.rl.prompt(); - } - } -} - -export async function startChat(error: Error, logs?: string[]) { - if (!process.env.GEMINI_API_KEY) { - throw new Error('GEMINI_API_KEY environment variable is not set.'); - } - - const config = new Config({ - sessionId, - targetDir: process.cwd(), - cwd: process.cwd(), - debugMode: false, - contentGeneratorConfig: { - apiKey: process.env.GEMINI_API_KEY, - model: DEFAULT_GEMINI_MODEL, - }, - coreTools: [LSTool.Name, ReadFileTool.Name, ShellTool.Name, WriteFileTool.Name, EditTool.Name], - }); - - const conversation = new InteractiveConversation(config); - await conversation.initialize(); - let initialMessage = `I encountered the following error during deployment: ${error.message}.`; - if (logs) { - initialMessage += `\n\nHere are the deployment logs:\n${logs.join("\n")}`; - } - initialMessage += "\n\nCan you help me debug it? Note: When using shell commands, please run them in the foreground to avoid issues with process tracking."; - await conversation.start(initialMessage); -} - diff --git a/src/gemini/cli.ts b/src/gemini/cli.ts new file mode 100644 index 00000000000..2af31ab84b4 --- /dev/null +++ b/src/gemini/cli.ts @@ -0,0 +1,196 @@ +import { spawnSync } from "child_process"; +import { logger } from "../logger"; +import { fileExistsSync } from "../fsutils"; +import * as fs from "fs"; +import * as path from "path"; +import { FirebaseError } from "../error"; +import * as pty from "@lydell/node-pty"; +import { bold } from "colorette"; +import { confirm } from "../prompt"; +import * as clc from "colorette"; + +// A more robust check without external dependencies. +export function isGeminiInstalled(): boolean { + const command = process.platform === "win32" ? "where" : "which"; + try { + const result = spawnSync(command, ["gemini"], { stdio: "ignore" }); + return result.status === 0; + } catch (e) { + // This might happen if 'which' or 'where' is not in the path, though it's highly unlikely. + logger.debug(`Failed to run '${command} gemini':`, e); + return false; + } +} + +export function configureProject(projectDir: string): void { + const geminiDir = path.join(projectDir, ".gemini"); + + try { + const stats = fs.statSync(geminiDir); + if (!stats.isDirectory()) { + logger.warn( + "Cannot configure the Firebase MCP server for the Gemini CLI because a file named '.gemini' exists in this directory.", + ); + logger.warn("The Gemini CLI requires a '.gemini' directory to store its settings."); + logger.warn("Please remove or rename the '.gemini' file to enable automatic configuration."); + return; // Exit the function, skipping configuration. + } + } catch (e: any) { + if (e.code === "ENOENT") { + // It doesn't exist, so create the directory. + try { + fs.mkdirSync(geminiDir); + } catch (mkdirErr: any) { + // Handle potential race conditions or permission errors + throw new FirebaseError(`Failed to create .gemini directory: ${mkdirErr.message}`, { + original: mkdirErr, + }); + } + } else { + // A different error occurred (e.g., permissions) + throw new FirebaseError(`Failed to stat .gemini path: ${e.message}`, { original: e }); + } + } + + // If we've reached this point, geminiDir is a valid directory. + // Proceed with reading/writing settings.json inside it. + const settingsPath = path.join(geminiDir, "settings.json"); + let settings: any = {}; + if (fileExistsSync(settingsPath)) { + try { + settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); + } catch (e: any) { + logger.debug(`Could not parse .gemini/settings.json: ${e.message}. It will be overwritten.`); + settings = {}; + } + } + + const mcpConfig = { + command: "npx", + args: ["-y", "firebase-tools@latest", "experimental:mcp"], + }; + + // Check if the config is already correct + if ( + settings.mcpServers && + settings.mcpServers.firebase && + JSON.stringify(settings.mcpServers.firebase) === JSON.stringify(mcpConfig) + ) { + logger.debug("Firebase MCP server for Gemini CLI is already configured."); + return; + } + + if (!settings.mcpServers) { + settings.mcpServers = {}; + } + settings.mcpServers.firebase = mcpConfig; + + try { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + logger.info("Configured Firebase MCP server for Gemini CLI."); + } catch (e: any) { + throw new FirebaseError(`Failed to write to .gemini/settings.json: ${e.message}`, { + original: e, + }); + } +} + +export async function promptAndLaunchGemini( + projectDir: string, + prompt: string, + retryAction?: () => Promise, +): Promise { + const startColor = { r: 66, g: 133, b: 244 }; // Google Blue + const endColor = { r: 219, g: 68, b: 55 }; // Google Red + const text = ">Gemini"; + + const createGradient = (str: string, start: { r: number, g: number, b: number }, end: { r: number, g: number, b: number }): string => { + const steps = str.length; + let output = ""; + for (let i = 0; i < steps; i++) { + const ratio = i / (steps - 1); + const r = Math.round(start.r + (end.r - start.r) * ratio); + const g = Math.round(start.g + (end.g - start.g) * ratio); + const b = Math.round(start.b + (end.b - start.b) * ratio); + // ANSI escape code for 24-bit truecolor + output += `\x1b[38;2;${r};${g};${b}m${str[i]}\x1b[0m`; + } + return output; + }; + + const colorizedGemini = createGradient(text, startColor, endColor); + + const choice = await confirm({ + message: `Debug with 'Open in ${colorizedGemini}'?`, + default: true, + }); + + if (choice) { + if (!isGeminiInstalled()) { + throw new FirebaseError( + "Gemini CLI not found. Please install it by running " + + clc.bold("npm install -g @gemini-cli/cli"), + ); + } + configureProject(projectDir); + const geminiStartTime = Date.now(); + await launchGemini(prompt); + const geminiDuration = Date.now() - geminiStartTime; + logger.info( + `Welcome back! Your Gemini session lasted for ${Math.round(geminiDuration / 1000)} seconds.`, + ); + + if (retryAction) { + const reDeploy = await confirm({ + message: "Would you like to try again?", + default: false, + }); + if (reDeploy) { + return retryAction(); + } + } + } +} + +export function launchGemini(prompt: string): Promise { + return new Promise((resolve, reject) => { + logger.info("Connecting to Gemini..."); + + const ptyProcess = pty.spawn("gemini", ["-i", "-p", prompt], { + name: "xterm-color", + cols: process.stdout.columns, + rows: process.stdout.rows, + cwd: process.cwd(), + env: process.env, + }); + + const dataListener = (data: Buffer): void => { + ptyProcess.write(data.toString()); + }; + process.stdin.on("data", dataListener); + + ptyProcess.onData((data) => { + process.stdout.write(data); + }); + + const onResize = (): void => { + ptyProcess.resize(process.stdout.columns, process.stdout.rows); + }; + process.stdout.on("resize", onResize); + + process.stdin.setRawMode(true); + process.stdin.resume(); + + ptyProcess.onExit(({ exitCode }) => { + process.stdout.removeListener("resize", onResize); + process.stdin.removeListener("data", dataListener); + process.stdin.setRawMode(false); + process.stdin.resume(); + if (exitCode !== 0) { + reject(new FirebaseError(`Gemini CLI exited with code ${exitCode}`)); + } else { + resolve(); + } + }); + }); +} From b93363e2b04911b69fc7baa91233be9f2573bf0a Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Thu, 26 Jun 2025 10:14:42 -0700 Subject: [PATCH 3/4] style: reformat. --- src/deploy/index.ts | 4 +--- src/gemini/cli.ts | 8 ++++++-- src/gemini/logger.ts | 23 +++++++++++------------ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 4de1a4f27d2..11fa1511f30 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -91,7 +91,7 @@ export const deploy = async function ( targetNames: (keyof typeof TARGETS)[], options: DeployOptions, customContext = {}, -): Promise<{ hosting: string | string[] | undefined }> { +) { const projectId = needProjectId(options); const payload = {}; // a shared context object for deploy targets to decorate as needed @@ -176,8 +176,6 @@ Can you help me debug this deployment failure? Note: When using shell commands, await promptAndLaunchGemini(options.cwd || process.cwd(), prompt, () => { return deploy(targetNames, options, customContext); }); - - return { hosting: undefined }; } const duration = Date.now() - startTime; diff --git a/src/gemini/cli.ts b/src/gemini/cli.ts index 2af31ab84b4..f301c3b6878 100644 --- a/src/gemini/cli.ts +++ b/src/gemini/cli.ts @@ -104,7 +104,11 @@ export async function promptAndLaunchGemini( const endColor = { r: 219, g: 68, b: 55 }; // Google Red const text = ">Gemini"; - const createGradient = (str: string, start: { r: number, g: number, b: number }, end: { r: number, g: number, b: number }): string => { + const createGradient = ( + str: string, + start: { r: number; g: number; b: number }, + end: { r: number; g: number; b: number }, + ): string => { const steps = str.length; let output = ""; for (let i = 0; i < steps; i++) { @@ -129,7 +133,7 @@ export async function promptAndLaunchGemini( if (!isGeminiInstalled()) { throw new FirebaseError( "Gemini CLI not found. Please install it by running " + - clc.bold("npm install -g @gemini-cli/cli"), + clc.bold("npm install -g @gemini-cli/cli"), ); } configureProject(projectDir); diff --git a/src/gemini/logger.ts b/src/gemini/logger.ts index 1a45e775311..627c0a7fee7 100644 --- a/src/gemini/logger.ts +++ b/src/gemini/logger.ts @@ -1,4 +1,3 @@ - import * as Transport from "winston-transport"; import { SPLAT } from "triple-beam"; import { stripVTControlCharacters } from "util"; @@ -9,14 +8,14 @@ export class MemoryLogger extends Transport { log(info: any, callback: () => void) { const segments = [info.message, ...(info[SPLAT] || [])].map((v) => { - if (typeof v === "string") { - return v; - } - try { - return JSON.stringify(v); - } catch (e) { - return v; - } + if (typeof v === "string") { + return v; + } + try { + return JSON.stringify(v); + } catch (e) { + return v; + } }); this.logs.push(stripVTControlCharacters(segments.join(" "))); callback(); @@ -26,10 +25,10 @@ export class MemoryLogger extends Transport { let memoryLogger: MemoryLogger | undefined; export function attachMemoryLogger() { - memoryLogger = new MemoryLogger(); - logger.add(memoryLogger); + memoryLogger = new MemoryLogger(); + logger.add(memoryLogger); } export function getLogs(): string[] { - return memoryLogger ? memoryLogger.logs : []; + return memoryLogger ? memoryLogger.logs : []; } From 95ddfd06b5950dbf2ad446c8bace03efa960979d Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Mon, 14 Jul 2025 10:56:41 -0700 Subject: [PATCH 4/4] --with-gemini demo. --- src/bin/cli.ts | 32 +++++++++ src/deploy/index.ts | 31 ++------- src/gemini/cli.ts | 160 +++++++++++++++++++++++++++++++++++++++++--- src/index.ts | 1 + 4 files changed, 188 insertions(+), 36 deletions(-) diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 32f7f324584..40e563aef53 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -17,6 +17,8 @@ import * as utils from "../utils"; import { enableExperimentsFromCliEnvVariable } from "../experiments"; import { fetchMOTD } from "../fetchMOTD"; +import { launchGeminiWithCommand } from "../gemini/cli"; +import { detectProjectRoot } from "../detectProjectRoot"; export function cli(pkg: any) { const updateNotifier = updateNotifierPkg({ pkg }); @@ -108,6 +110,36 @@ export function cli(pkg: any) { }); if (!handlePreviewToggles(args)) { + // Check if --with-gemini flag is present + const withGeminiIndex = args.indexOf("--with-gemini"); + if (withGeminiIndex !== -1) { + // Remove --with-gemini from args + const cleanArgs = [...args]; + cleanArgs.splice(withGeminiIndex, 1); + + // Extract command and remaining args + if (cleanArgs.length === 0) { + // No command specified, just show help in Gemini + const projectDir = detectProjectRoot({}) || process.cwd(); + launchGeminiWithCommand("help", [], projectDir, client).catch((err) => { + errorOut(err); + }); + return; + } + + // Get the command (first non-flag argument) + const command = cleanArgs[0]; + const commandArgs = cleanArgs.slice(1); + + // Launch Gemini with the command context + const projectDir = detectProjectRoot({}) || process.cwd(); + launchGeminiWithCommand(command, commandArgs, projectDir, client).catch((err) => { + errorOut(err); + }); + return; + } + + // Normal flow without --with-gemini // determine if there are any arguments. if not, display help if (!args.length) { client.cli.help(); diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 11fa1511f30..fa3923c64bf 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -29,8 +29,6 @@ import { requirePermissions } from "../requirePermissions"; import { Options } from "../options"; import { HostingConfig } from "../firebaseConfig"; import { confirm } from "../prompt"; -import { promptAndLaunchGemini } from "../gemini/cli"; -import { attachMemoryLogger, getLogs } from "../gemini/logger"; const TARGETS = { hosting: HostingTarget, @@ -153,30 +151,11 @@ export const deploy = async function ( logBullet("deploying " + bold(targetNames.join(", "))); - attachMemoryLogger(); - try { - await chain(predeploys, context, options, payload); - await chain(prepares, context, options, payload); - await chain(deploys, context, options, payload); - await chain(releases, context, options, payload); - await chain(postdeploys, context, options, payload); - } catch (err: any) { - logError(err); - - const logs = getLogs(); - const failedTargets = targetNames.join(", "); - const prompt = `I encountered an error during a Firebase deployment for the following services: ${failedTargets}. -Error: ${err.message} - -Here are the deployment logs: -${logs.join("\n")} - -Can you help me debug this deployment failure? Note: When using shell commands, please run them in the foreground to avoid issues with process tracking.`; - - await promptAndLaunchGemini(options.cwd || process.cwd(), prompt, () => { - return deploy(targetNames, options, customContext); - }); - } + await chain(predeploys, context, options, payload); + await chain(prepares, context, options, payload); + await chain(deploys, context, options, payload); + await chain(releases, context, options, payload); + await chain(postdeploys, context, options, payload); const duration = Date.now() - startTime; const analyticsParams: AnalyticsParams = { diff --git a/src/gemini/cli.ts b/src/gemini/cli.ts index f301c3b6878..b0af44bfa11 100644 --- a/src/gemini/cli.ts +++ b/src/gemini/cli.ts @@ -160,14 +160,50 @@ export function launchGemini(prompt: string): Promise { return new Promise((resolve, reject) => { logger.info("Connecting to Gemini..."); - const ptyProcess = pty.spawn("gemini", ["-i", "-p", prompt], { + const ptyProcess = pty.spawn("gemini", ["-i", prompt], { name: "xterm-color", cols: process.stdout.columns, rows: process.stdout.rows, cwd: process.cwd(), env: process.env, + handleFlowControl: true, }); + // Store original handlers + const originalSigintListeners = process.listeners("SIGINT"); + const originalSigtermListeners = process.listeners("SIGTERM"); + + // Remove all existing SIGINT/SIGTERM handlers + process.removeAllListeners("SIGINT"); + process.removeAllListeners("SIGTERM"); + + const cleanup = (): void => { + process.stdout.removeListener("resize", onResize); + process.stdin.removeListener("data", dataListener); + + // Restore original signal handlers + process.removeAllListeners("SIGINT"); + process.removeAllListeners("SIGTERM"); + originalSigintListeners.forEach((listener) => process.on("SIGINT", listener)); + originalSigtermListeners.forEach((listener) => process.on("SIGTERM", listener)); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.resume(); + }; + + // Handle signals by forwarding to PTY process + const signalHandler = (signal: NodeJS.Signals) => { + return () => { + // Forward the signal to the PTY process + ptyProcess.kill(signal); + }; + }; + + process.on("SIGINT", signalHandler("SIGINT")); + process.on("SIGTERM", signalHandler("SIGTERM")); + const dataListener = (data: Buffer): void => { ptyProcess.write(data.toString()); }; @@ -186,15 +222,119 @@ export function launchGemini(prompt: string): Promise { process.stdin.resume(); ptyProcess.onExit(({ exitCode }) => { - process.stdout.removeListener("resize", onResize); - process.stdin.removeListener("data", dataListener); - process.stdin.setRawMode(false); - process.stdin.resume(); - if (exitCode !== 0) { - reject(new FirebaseError(`Gemini CLI exited with code ${exitCode}`)); - } else { - resolve(); - } + cleanup(); + // Since we're just a launcher for Gemini, exit immediately when Gemini exits + process.exit(exitCode || 0); }); }); } + +/** + * Extracts help information for a given Firebase command. + * @param commandName The command name to get help for (e.g., "deploy", "functions:delete") + * @param client The firebase client object (passed in to avoid circular dependency) + * @return The help text for the command, or an error message if not found + */ +export function getCommandHelp(commandName: string, client: any): string { + const cmd = client.getCommand(commandName); + if (cmd) { + // Commander's outputHelp() writes to stdout, so we need to capture it + const originalWrite = process.stdout.write; + let helpText = ""; + process.stdout.write = (chunk: any): boolean => { + helpText += chunk; + return true; + }; + + try { + cmd.outputHelp(); + } finally { + process.stdout.write = originalWrite; + } + + return helpText; + } + return `Command '${commandName}' not found. Run 'firebase help' to see available commands.`; +} + +/** + * Launches Gemini CLI with a Firebase command context. + * @param command The Firebase command to run (e.g., "deploy", "functions:delete") + * @param args The arguments passed to the command + * @param projectDir The project directory + * @param client The firebase client object (passed in to avoid circular dependency) + */ +export async function launchGeminiWithCommand( + command: string, + args: string[], + projectDir: string, + client: any, +): Promise { + // Check if Gemini is installed + if (!isGeminiInstalled()) { + logger.error( + "Gemini CLI not found. To use the --with-gemini feature, please install Gemini CLI:\n" + + "\n" + + clc.bold(" npm install -g @google/gemini-cli") + "\n" + + "\n" + + "Or run it temporarily with npx:\n" + + "\n" + + clc.bold(" npx @google/gemini-cli -i \"Your prompt here\"") + "\n" + + "\n" + + "Learn more at: https://github.com/google-gemini/gemini-cli" + ); + return; + } + + // Configure the project for MCP + configureProject(projectDir); + + let prompt: string; + + // Special handling for when no command is provided + if (command === "help" && args.length === 0) { + prompt = `You are an AI assistant helping with Firebase CLI commands. The user has launched Firebase with the --with-gemini flag but hasn't specified a particular command. + +You have access to the Firebase CLI through the MCP server that's already configured. + +Please ask the user what they would like to do with Firebase. Some common tasks include: +- Deploying functions, hosting, or other services +- Managing Firebase projects +- Working with Firestore, Realtime Database, or Storage +- Setting up authentication +- Managing extensions + +Current working directory: ${projectDir} + +What would you like to help the user accomplish?`; + } else { + // Get help text for the command + const helpText = getCommandHelp(command, client); + + // Build the full command string + const fullCommand = `firebase ${command} ${args.join(" ")}`.trim(); + + // Build the context prompt + prompt = `You are helping a user run a Firebase CLI command. The user wants to run: + +${fullCommand} + +Here is the help documentation for this command: + +${helpText} + +Please do the following: +1. First, analyze if the command has all required flags and arguments +2. If the command appears complete and ready to run (like "firebase deploy --only functions"), go ahead and execute it immediately using the Firebase MCP server +3. If the command is missing required information or could benefit from additional options, ask the user for clarification +4. Explain what the command will do before or after running it + +The user has access to the Firebase CLI through the MCP server that's already configured. + +Current working directory: ${projectDir}`; + } + + // Launch Gemini with the context + logger.info("Launching Gemini CLI with Firebase command context..."); + await launchGemini(prompt); +} diff --git a/src/index.ts b/src/index.ts index d3444998cd3..26ac94d48fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ program.option("--non-interactive", "error out of the command instead of waiting program.option("-i, --interactive", "force prompts to be displayed"); program.option("--debug", "print verbose debug output and keep a debug log file"); program.option("-c, --config ", "path to the firebase.json file to use for configuration"); +program.option("--with-gemini", "launch the command in Gemini CLI with AI assistance"); const client = { cli: program,