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/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 7dd2d093a2b..fa3923c64bf 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"; @@ -26,6 +28,7 @@ import { TARGET_PERMISSIONS } from "../commands/deploy"; import { requirePermissions } from "../requirePermissions"; import { Options } from "../options"; import { HostingConfig } from "../firebaseConfig"; +import { confirm } from "../prompt"; const TARGETS = { hosting: HostingTarget, diff --git a/src/gemini/cli.ts b/src/gemini/cli.ts new file mode 100644 index 00000000000..b0af44bfa11 --- /dev/null +++ b/src/gemini/cli.ts @@ -0,0 +1,340 @@ +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", 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()); + }; + 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 }) => { + 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/gemini/logger.ts b/src/gemini/logger.ts new file mode 100644 index 00000000000..627c0a7fee7 --- /dev/null +++ b/src/gemini/logger.ts @@ -0,0 +1,34 @@ +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 : []; +} 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,