/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import fs from 'node:fs';
import path from 'node:path';
import os, { EOL } from 'node:os';
import crypto from 'node:crypto';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import { ToolErrorType } from './tool-error.js';
import { BaseDeclarativeTool, BaseToolInvocation, ToolConfirmationOutcome, Kind, } from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { getCommandRoots, isCommandAllowed, isCommandNeedsPermission, stripShellWrapper, } from '../utils/shell-utils.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
export class ShellToolInvocation extends BaseToolInvocation {
    config;
    allowlist;
    constructor(config, params, allowlist) {
        super(params);
        this.config = config;
        this.allowlist = allowlist;
    }
    getDescription() {
        let description = `${this.params.command}`;
        // append optional [in directory]
        // note description is needed even if validation fails due to absolute path
        if (this.params.directory) {
            description += ` [in ${this.params.directory}]`;
        }
        // append background indicator
        if (this.params.is_background) {
            description += ` [background]`;
        }
        // append optional (description), replacing any line breaks with spaces
        if (this.params.description) {
            description += ` (${this.params.description.replace(/\n/g, ' ')})`;
        }
        return description;
    }
    async shouldConfirmExecute(_abortSignal) {
        const command = stripShellWrapper(this.params.command);
        const rootCommands = [...new Set(getCommandRoots(command))];
        const commandsToConfirm = rootCommands.filter((command) => !this.allowlist.has(command));
        if (commandsToConfirm.length === 0) {
            return false; // already approved and allowlisted
        }
        const permissionCheck = isCommandNeedsPermission(command);
        if (!permissionCheck.requiresPermission) {
            return false;
        }
        const confirmationDetails = {
            type: 'exec',
            title: 'Confirm Shell Command',
            command: this.params.command,
            rootCommand: commandsToConfirm.join(', '),
            onConfirm: async (outcome) => {
                if (outcome === ToolConfirmationOutcome.ProceedAlways) {
                    commandsToConfirm.forEach((command) => this.allowlist.add(command));
                }
            },
        };
        return confirmationDetails;
    }
    async execute(signal, updateOutput, shellExecutionConfig, setPidCallback) {
        const strippedCommand = stripShellWrapper(this.params.command);
        if (signal.aborted) {
            return {
                llmContent: 'Command was cancelled by user before it could start.',
                returnDisplay: 'Command cancelled by user.',
            };
        }
        const isWindows = os.platform() === 'win32';
        const tempFileName = `shell_pgrep_${crypto
            .randomBytes(6)
            .toString('hex')}.tmp`;
        const tempFilePath = path.join(os.tmpdir(), tempFileName);
        try {
            // Add co-author to git commit commands
            const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
            const shouldRunInBackground = this.params.is_background;
            let finalCommand = processedCommand;
            // If explicitly marked as background and doesn't already end with &, add it
            if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) {
                finalCommand = finalCommand.trim() + ' &';
            }
            // pgrep is not available on Windows, so we can't get background PIDs
            const commandToExecute = isWindows
                ? finalCommand
                : (() => {
                    // wrap command to append subprocess pids (via pgrep) to temporary file
                    let command = finalCommand.trim();
                    if (!command.endsWith('&'))
                        command += ';';
                    return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
                })();
            const cwd = this.params.directory || this.config.getTargetDir();
            let cumulativeOutput = '';
            let lastUpdateTime = Date.now();
            let isBinaryStream = false;
            const { result: resultPromise, pid } = await ShellExecutionService.execute(commandToExecute, cwd, (event) => {
                if (!updateOutput) {
                    return;
                }
                let shouldUpdate = false;
                switch (event.type) {
                    case 'data':
                        if (isBinaryStream)
                            break;
                        cumulativeOutput = event.chunk;
                        shouldUpdate = true;
                        break;
                    case 'binary_detected':
                        isBinaryStream = true;
                        cumulativeOutput =
                            '[Binary output detected. Halting stream...]';
                        shouldUpdate = true;
                        break;
                    case 'binary_progress':
                        isBinaryStream = true;
                        cumulativeOutput = `[Receiving binary output... ${formatMemoryUsage(event.bytesReceived)} received]`;
                        if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
                            shouldUpdate = true;
                        }
                        break;
                    default: {
                        throw new Error('An unhandled ShellOutputEvent was found.');
                    }
                }
                if (shouldUpdate) {
                    updateOutput(typeof cumulativeOutput === 'string'
                        ? cumulativeOutput
                        : { ansiOutput: cumulativeOutput });
                    lastUpdateTime = Date.now();
                }
            }, signal, this.config.getShouldUseNodePtyShell(), shellExecutionConfig ?? {});
            if (pid && setPidCallback) {
                setPidCallback(pid);
            }
            const result = await resultPromise;
            const backgroundPIDs = [];
            if (os.platform() !== 'win32') {
                if (fs.existsSync(tempFilePath)) {
                    const pgrepLines = fs
                        .readFileSync(tempFilePath, 'utf8')
                        .split(EOL)
                        .filter(Boolean);
                    for (const line of pgrepLines) {
                        if (!/^\d+$/.test(line)) {
                            console.error(`pgrep: ${line}`);
                        }
                        const pid = Number(line);
                        if (pid !== result.pid) {
                            backgroundPIDs.push(pid);
                        }
                    }
                }
                else {
                    if (!signal.aborted) {
                        console.error('missing pgrep output');
                    }
                }
            }
            let llmContent = '';
            if (result.aborted) {
                llmContent = 'Command was cancelled by user before it could complete.';
                if (result.output.trim()) {
                    llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
                }
                else {
                    llmContent += ' There was no output before it was cancelled.';
                }
            }
            else {
                // Create a formatted error string for display, replacing the wrapper command
                // with the user-facing command.
                const finalError = result.error
                    ? result.error.message.replace(commandToExecute, this.params.command)
                    : '(none)';
                llmContent = [
                    `Command: ${this.params.command}`,
                    `Directory: ${this.params.directory || '(root)'}`,
                    `Output: ${result.output || '(empty)'}`,
                    `Error: ${finalError}`, // Use the cleaned error string.
                    `Exit Code: ${result.exitCode ?? '(none)'}`,
                    `Signal: ${result.signal ?? '(none)'}`,
                    `Background PIDs: ${backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'}`,
                    `Process Group PGID: ${result.pid ?? '(none)'}`,
                ].join('\n');
            }
            let returnDisplayMessage = '';
            if (this.config.getDebugMode()) {
                returnDisplayMessage = llmContent;
            }
            else {
                if (result.output.trim()) {
                    returnDisplayMessage = result.output;
                }
                else {
                    if (result.aborted) {
                        returnDisplayMessage = 'Command cancelled by user.';
                    }
                    else if (result.signal) {
                        returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
                    }
                    else if (result.error) {
                        returnDisplayMessage = `Command failed: ${getErrorMessage(result.error)}`;
                    }
                    else if (result.exitCode !== null && result.exitCode !== 0) {
                        returnDisplayMessage = `Command exited with code: ${result.exitCode}`;
                    }
                    // If output is empty and command succeeded (code 0, no error/signal/abort),
                    // returnDisplayMessage will remain empty, which is fine.
                }
            }
            const summarizeConfig = this.config.getSummarizeToolOutputConfig();
            const executionError = result.error
                ? {
                    error: {
                        message: result.error.message,
                        type: ToolErrorType.SHELL_EXECUTE_ERROR,
                    },
                }
                : {};
            if (summarizeConfig && summarizeConfig[ShellTool.Name]) {
                const summary = await summarizeToolOutput(llmContent, this.config.getGeminiClient(), signal, summarizeConfig[ShellTool.Name].tokenBudget);
                return {
                    llmContent: summary,
                    returnDisplay: returnDisplayMessage,
                    ...executionError,
                };
            }
            return {
                llmContent,
                returnDisplay: returnDisplayMessage,
                ...executionError,
            };
        }
        finally {
            if (fs.existsSync(tempFilePath)) {
                fs.unlinkSync(tempFilePath);
            }
        }
    }
    addCoAuthorToGitCommit(command) {
        // Check if co-author feature is enabled
        const gitCoAuthorSettings = this.config.getGitCoAuthor();
        if (!gitCoAuthorSettings.enabled) {
            return command;
        }
        // Check if this is a git commit command
        const gitCommitPattern = /^git\s+commit/;
        if (!gitCommitPattern.test(command.trim())) {
            return command;
        }
        // Define the co-author line using configuration
        const coAuthor = `

Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
        // Handle different git commit patterns
        // Match -m "message" or -m 'message'
        const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
        const match = command.match(messagePattern);
        if (match) {
            const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
            const newMessage = existingMessage + coAuthor;
            const replacement = prefix + quote + newMessage + closingQuote;
            return command.replace(fullMatch, replacement);
        }
        // If no -m flag found, the command might open an editor
        // In this case, we can't easily modify it, so return as-is
        return command;
    }
}
function getShellToolDescription() {
    const toolDescription = `

      **Background vs Foreground Execution:**
      You should decide whether commands should run in background or foreground based on their nature:
      
      **Use background execution (is_background: true) for:**
      - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
      - Build watchers: \`npm run watch\`, \`webpack --watch\`
      - Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
      - Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
      - Any command expected to run indefinitely until manually stopped
      
      **Use foreground execution (is_background: false) for:**
      - One-time commands: \`ls\`, \`cat\`, \`grep\`
      - Build commands: \`npm run build\`, \`make\`
      - Installation commands: \`npm install\`, \`pip install\`
      - Git operations: \`git commit\`, \`git push\`
      - Test runs: \`npm test\`, \`pytest\`
      
      The following information is returned:

      Command: Executed command.
      Directory: Directory where command was executed, or \`(root)\`.
      Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
      Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
      Error: Error or \`(none)\` if no error was reported for the subprocess.
      Exit Code: Exit code or \`(none)\` if terminated by signal.
      Signal: Signal number or \`(none)\` if no signal was received.
      Background PIDs: List of background processes started or \`(none)\`.
      Process Group PGID: Process group started or \`(none)\``;
    if (os.platform() === 'win32') {
        return `This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.${toolDescription}`;
    }
    else {
        return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${toolDescription}`;
    }
}
function getCommandDescription() {
    const cmd_substitution_warning = '\n*** WARNING: Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons.';
    if (os.platform() === 'win32') {
        return ('Exact command to execute as `cmd.exe /c <command>`' +
            cmd_substitution_warning);
    }
    else {
        return ('Exact bash command to execute as `bash -c <command>`' +
            cmd_substitution_warning);
    }
}
export class ShellTool extends BaseDeclarativeTool {
    config;
    static Name = ToolNames.SHELL;
    allowlist = new Set();
    constructor(config) {
        super(ShellTool.Name, ToolDisplayNames.SHELL, getShellToolDescription(), Kind.Execute, {
            type: 'object',
            properties: {
                command: {
                    type: 'string',
                    description: getCommandDescription(),
                },
                is_background: {
                    type: 'boolean',
                    description: 'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
                },
                description: {
                    type: 'string',
                    description: 'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
                },
                directory: {
                    type: 'string',
                    description: '(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
                },
            },
            required: ['command', 'is_background'],
        }, false, // output is not markdown
        true);
        this.config = config;
    }
    validateToolParamValues(params) {
        const commandCheck = isCommandAllowed(params.command, this.config);
        if (!commandCheck.allowed) {
            if (!commandCheck.reason) {
                console.error('Unexpected: isCommandAllowed returned false without a reason');
                return `Command is not allowed: ${params.command}`;
            }
            return commandCheck.reason;
        }
        if (!params.command.trim()) {
            return 'Command cannot be empty.';
        }
        if (getCommandRoots(params.command).length === 0) {
            return 'Could not identify command root to obtain permission from user.';
        }
        if (params.directory) {
            if (!path.isAbsolute(params.directory)) {
                return 'Directory must be an absolute path.';
            }
            const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
            const isWithinWorkspace = workspaceDirs.some((wsDir) => params.directory.startsWith(wsDir));
            if (!isWithinWorkspace) {
                return `Directory '${params.directory}' is not within any of the registered workspace directories.`;
            }
        }
        return null;
    }
    createInvocation(params) {
        return new ShellToolInvocation(this.config, params, this.allowlist);
    }
}
//# sourceMappingURL=shell.js.map