summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/shellCommandProcessor.ts
blob: 35935e7d27c6a9e307a3116658e3103fbe80b2c6 (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
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { exec as defaultExec } from 'child_process';
import type { exec as ExecType } from 'child_process';
import { useCallback } from 'react';
import { Config } from '@gemini-code/server';
import { type PartListUnion } from '@google/genai';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import crypto from 'crypto';
import path from 'path';
import os from 'os';
import fs from 'fs';

/**
 * Hook to process shell commands (e.g., !ls, $pwd).
 * Executes the command in the target directory and adds output/errors to history.
 */
export const useShellCommandProcessor = (
  addItemToHistory: UseHistoryManagerReturn['addItem'],
  onExec: (command: Promise<void>) => void,
  onDebugMessage: (message: string) => void,
  config: Config,
  executeCommand: typeof ExecType = defaultExec, // Injectable for testing
) => {
  /**
   * Checks if the query is a shell command, executes it, and adds results to history.
   * @returns True if the query was handled as a shell command, false otherwise.
   */
  const handleShellCommand = useCallback(
    (rawQuery: PartListUnion): boolean => {
      if (typeof rawQuery !== 'string') {
        return false;
      }

      let commandToExecute = rawQuery.trim().trimStart();

      // wrap command to write pwd to temporary file
      const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`;
      const pwdFilePath = path.join(os.tmpdir(), pwdFileName);
      if (!commandToExecute.endsWith('&')) commandToExecute += ';';
      // note here we could also restore a previous pwd with `cd {cwd}; { ... }`
      commandToExecute = `{ ${commandToExecute} }; pwd >${pwdFilePath}`;

      const userMessageTimestamp = Date.now();
      addItemToHistory(
        { type: 'user_shell', text: rawQuery },
        userMessageTimestamp,
      );

      if (rawQuery.trim() === '') {
        addItemToHistory(
          { type: 'error', text: 'Empty shell command.' },
          userMessageTimestamp,
        );
        return true; // Handled (by showing error)
      }

      const targetDir = config.getTargetDir();
      onDebugMessage(
        `Executing shell command in ${targetDir}: ${commandToExecute}`,
      );
      const execOptions = {
        cwd: targetDir,
      };

      const execPromise = new Promise<void>((resolve) => {
        executeCommand(
          commandToExecute,
          execOptions,
          (error, stdout, stderr) => {
            if (error) {
              addItemToHistory(
                { type: 'error', text: error.message },
                userMessageTimestamp,
              );
            } else {
              let output = '';
              if (stdout) output += stdout;
              if (stderr) output += (output ? '\n' : '') + stderr; // Include stderr as info

              addItemToHistory(
                {
                  type: 'info',
                  text: output || '(Command produced no output)',
                },
                userMessageTimestamp,
              );
            }
            if (fs.existsSync(pwdFilePath)) {
              const pwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
              if (pwd !== targetDir) {
                addItemToHistory(
                  {
                    type: 'info',
                    text: `WARNING: shell mode is stateless; \`cd ${pwd}\` will not apply to next command`,
                  },
                  userMessageTimestamp,
                );
              }
              fs.unlinkSync(pwdFilePath);
            }
            resolve();
          },
        );
      });

      try {
        onExec(execPromise);
      } catch (_e) {
        // silently ignore errors from this since it's from the caller
      }

      return true; // Command was initiated
    },
    [config, onDebugMessage, addItemToHistory, onExec, executeCommand],
  );

  return { handleShellCommand };
};