summaryrefslogtreecommitdiff
path: root/packages/cli/src/gemini.ts
blob: 14e5db82949d8fc690482809e17df8fb2a070473 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import os from 'os';
import path from 'path';
import fs from 'fs';
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 } from '@gemini-code/server';
import { readPackageUp } from 'read-package-up';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { execSync, spawnSync } from 'child_process';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// node.js equivalent of scripts/sandbox_command.sh
function sandbox_command(): string {
  const sandbox = process.env.GEMINI_CODE_SANDBOX?.toLowerCase().trim() ?? '';
  const opts: object = { stdio: 'ignore' };
  if (['1', 'true'].includes(sandbox)) {
    // look for docker or podman, in that order
    if (spawnSync('command', ['-v', 'docker'], opts).status === 0) {
      return 'docker'; // Set sandbox to 'docker' if found
    } else if (spawnSync('command', ['-v', 'podman'], opts).status === 0) {
      return 'podman'; // Set sandbox to 'podman' if found
    } else {
      console.error(
        'ERROR: failed to determine command for sandbox; ' +
          'install docker or podman or specify command in GEMINI_CODE_SANDBOX',
      );
      process.exit(1);
    }
  } else if (sandbox) {
    // confirm that specfied command exists
    if (spawnSync('command', ['-v', sandbox], opts).status !== 0) {
      console.error(
        `ERROR: missing sandbox command '${sandbox}' (from GEMINI_CODE_SANDBOX)`,
      );
      process.exit(1);
    }
    return sandbox;
  } else {
    return ''; // no sandbox
  }
}

// node.js equivalent of scripts/start_sandbox.sh
function start_sandbox(sandbox: string) {
  // determine full path for gemini-code to distinguish linked vs installed setting
  const gcPath = execSync(`realpath $(which gemini-code)`).toString().trim();

  // stop if image is missing
  const image = 'gemini-code-sandbox';
  if (!execSync(`${sandbox} images -q ${image}`).toString().trim()) {
    const remedy = gcPath.includes('gemini-code/packages/')
      ? 'Try `scripts/build_sandbox.sh` under gemini-code repo.'
      : 'Please notify [email protected].';
    console.error(`ERROR: ${image} is missing. ${remedy}`);
    process.exit(1);
  }

  // stop if debugging in sandbox using linked/installed gemini-code
  // note this is because it does not work (unclear why, parent process interferes somehow)
  // note `npm run debug` runs sandbox directly and avoids any interference from parent process
  if (process.env.DEBUG) {
    console.error(
      'ERROR: cannot debug in sandbox using linked/installed gemini-code; ' +
        'use `npm run debug` under gemini-code repo instead',
    );
    process.exit(1);
  }

  // if project is gemini-code, then run sandboxed CLI from ${workdir}/packages/cli
  // otherwise refuse debug mode (see comments in launch.json around remoteRoot)
  const project = path.basename(process.cwd());
  const workdir = `/sandbox/${project}`;
  let cliPath = '/usr/local/share/npm-global/lib/node_modules/@gemini-code/cli';
  if (project === 'gemini-code') {
    cliPath = `${workdir}/packages/cli`;
  } else if (process.env.DEBUG) {
    console.error('ERROR: cannot debug in sandbox outside gemini-code repo');
    process.exit(1);
  }

  // use interactive tty mode and auto-remove container on exit
  // run init binary inside container to forward signals & reap zombies
  const args = ['run', '-it', '--rm', '--init', '--workdir', workdir];

  // mount current directory as ${workdir} inside container
  args.push('-v', `${process.cwd()}:${workdir}`);

  // mount os.tmpdir() as /tmp inside container
  args.push('-v', `${os.tmpdir()}:/tmp`);

  // name container after image, plus numeric suffix to avoid conflicts
  let index = 0;
  while (
    execSync(
      `${sandbox} ps -a --format "{{.Names}}" | grep "${image}-${index}" || true`,
    )
      .toString()
      .trim()
  ) {
    index++;
  }
  args.push('--name', `${image}-${index}`, '--hostname', `${image}-${index}`);

  // copy GEMINI_API_KEY
  if (process.env.GEMINI_API_KEY) {
    args.push('--env', `GEMINI_API_KEY=${process.env.GEMINI_API_KEY}`);
  }

  // copy GEMINI_CODE_MODEL
  if (process.env.GEMINI_CODE_MODEL) {
    args.push('--env', `GEMINI_CODE_MODEL=${process.env.GEMINI_CODE_MODEL}`);
  }

  // copy SHELL_TOOL to optionally enable shell tool
  if (process.env.SHELL_TOOL) {
    args.push('--env', `SHELL_TOOL=${process.env.SHELL_TOOL}`);
  }

  // copy TERM and COLORTERM to try to maintain terminal setup
  if (process.env.TERM) {
    args.push('--env', `TERM=${process.env.TERM}`);
  }
  if (process.env.COLORTERM) {
    args.push('--env', `COLORTERM=${process.env.COLORTERM}`);
  }

  // set SANDBOX as container name
  args.push('--env', `SANDBOX=${image}-${index}`);

  // for podman, use empty --authfile to skip unnecessary auth refresh overhead
  const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json');
  fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8');
  args.push('--authfile', emptyAuthFilePath);

  // enable debugging via node --inspect-brk if DEBUG is set
  const nodeArgs = [];
  const debugPort = process.env.DEBUG_PORT || '9229';
  if (process.env.DEBUG) {
    args.push('-p', `${debugPort}:${debugPort}`);
    nodeArgs.push('--inspect-brk', `0.0.0.0:${debugPort}`);
  }

  // append remaining args (image, node, node args, cli path, cli args)
  args.push(image, 'node', ...nodeArgs, cliPath, ...process.argv.slice(2));

  // spawn child and let it inherit stdio
  spawnSync(sandbox, args, { stdio: 'inherit' });
}

async function main() {
  const config = loadCliConfig();
  let input = config.getQuestion();

  // hop into sandbox if enabled but outside
  const sandbox = sandbox_command();
  if (sandbox && !process.env.SANDBOX) {
    console.log('hopping into sandbox ...');
    start_sandbox(sandbox);
    process.exit(0);
  }

  // Render UI, passing necessary config values. Check that there is no command line question.
  if (process.stdin.isTTY && input?.length === 0) {
    const readUpResult = await readPackageUp({ cwd: __dirname });
    const cliVersion =
      process.env.CLI_VERSION || readUpResult?.packageJson.version || 'unknown';

    render(
      React.createElement(App, {
        config,
        cliVersion,
      }),
    );
    return;
  }
  // If not a TTY, read from stdin
  // This is for cases where the user pipes input directly into the command
  if (!process.stdin.isTTY) {
    input += await readStdin();
  }
  if (!input) {
    console.error('No input provided via stdin.');
    process.exit(1);
  }

  // If not a TTY and we have initial input, process it directly
  const geminiClient = new GeminiClient(config);
  const chat = await geminiClient.startChat();
  try {
    for await (const event of geminiClient.sendMessageStream(chat, [
      { text: input },
    ])) {
      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);
  }
}

// --- Global Unhandled Rejection Handler ---
process.on('unhandledRejection', (reason, _promise) => {
  // Check if this is the known 429 ClientError that sometimes escapes
  // this is a workaround for a specific issue with the way we are calling gemini
  // where a 429 error is thrown but not caught, causing an unhandled rejection
  // TODO(adh): Remove this when the race condition is fixed
  const isKnownEscaped429 =
    reason instanceof Error &&
    reason.name === 'ClientError' &&
    reason.message.includes('got status: 429');

  if (isKnownEscaped429) {
    // Log it differently and DON'T exit, as it's likely already handled visually
    console.warn('-----------------------------------------');
    console.warn(
      'WORKAROUND: Suppressed known escaped 429 Unhandled Rejection.',
    );
    console.warn('-----------------------------------------');
    console.warn('Reason:', reason);
    return;
    // No process.exit(1); Don't exit.
  }

  // Log other unexpected unhandled rejections as critical errors
  console.error('=========================================');
  console.error('CRITICAL: Unhandled Promise Rejection!');
  console.error('=========================================');
  console.error('Reason:', reason);
  console.error('Stack trace may follow:');
  if (!(reason instanceof Error)) {
    console.error(reason);
  }
  // Exit for genuinely unhandled errors
  process.exit(1);
});

// --- Global Entry Point ---
main().catch((error) => {
  console.error('An unexpected critical error occurred:');
  if (error instanceof Error) {
    console.error(error.message);
  } else {
    console.error(String(error));
  }
  process.exit(1);
});