| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- import fs from "fs/promises"
- import * as path from "path"
- import delay from "delay"
- import { Cline } from "../Cline"
- import { CommandExecutionStatus } from "../../schemas"
- import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools"
- import { formatResponse } from "../prompts/responses"
- import { unescapeHtmlEntities } from "../../utils/text-normalization"
- import { telemetryService } from "../../services/telemetry/TelemetryService"
- import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
- import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
- import { Terminal } from "../../integrations/terminal/Terminal"
- class ShellIntegrationError extends Error {}
- export async function executeCommandTool(
- cline: Cline,
- block: ToolUse,
- askApproval: AskApproval,
- handleError: HandleError,
- pushToolResult: PushToolResult,
- removeClosingTag: RemoveClosingTag,
- ) {
- let command: string | undefined = block.params.command
- const customCwd: string | undefined = block.params.cwd
- try {
- if (block.partial) {
- await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
- return
- } else {
- if (!command) {
- cline.consecutiveMistakeCount++
- cline.recordToolError("execute_command")
- pushToolResult(await cline.sayAndCreateMissingParamError("execute_command", "command"))
- return
- }
- const ignoredFileAttemptedToAccess = cline.rooIgnoreController?.validateCommand(command)
- if (ignoredFileAttemptedToAccess) {
- await cline.say("rooignore_error", ignoredFileAttemptedToAccess)
- pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess)))
- return
- }
- cline.consecutiveMistakeCount = 0
- const executionId = Date.now().toString()
- command = unescapeHtmlEntities(command) // Unescape HTML entities.
- const didApprove = await askApproval("command", command, { id: executionId })
- if (!didApprove) {
- return
- }
- const clineProvider = await cline.providerRef.deref()
- const clineProviderState = await clineProvider?.getState()
- const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
- const options: ExecuteCommandOptions = {
- executionId,
- command,
- customCwd,
- terminalShellIntegrationDisabled,
- terminalOutputLineLimit,
- }
- try {
- const [rejected, result] = await executeCommand(cline, options)
- if (rejected) {
- cline.didRejectTool = true
- }
- pushToolResult(result)
- } catch (error: unknown) {
- const status: CommandExecutionStatus = { executionId, status: "fallback" }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- await cline.say("shell_integration_warning")
- if (error instanceof ShellIntegrationError) {
- const [rejected, result] = await executeCommand(cline, {
- ...options,
- terminalShellIntegrationDisabled: true,
- })
- if (rejected) {
- cline.didRejectTool = true
- }
- pushToolResult(result)
- } else {
- pushToolResult(`Command failed to execute in terminal due to a shell integration error.`)
- }
- }
- return
- }
- } catch (error) {
- await handleError("executing command", error)
- return
- }
- }
- export type ExecuteCommandOptions = {
- executionId: string
- command: string
- customCwd?: string
- terminalShellIntegrationDisabled?: boolean
- terminalOutputLineLimit?: number
- }
- export async function executeCommand(
- cline: Cline,
- {
- executionId,
- command,
- customCwd,
- terminalShellIntegrationDisabled = false,
- terminalOutputLineLimit = 500,
- }: ExecuteCommandOptions,
- ): Promise<[boolean, ToolResponse]> {
- let workingDir: string
- if (!customCwd) {
- workingDir = cline.cwd
- } else if (path.isAbsolute(customCwd)) {
- workingDir = customCwd
- } else {
- workingDir = path.resolve(cline.cwd, customCwd)
- }
- try {
- await fs.access(workingDir)
- } catch (error) {
- return [false, `Working directory '${workingDir}' does not exist.`]
- }
- let message: { text?: string; images?: string[] } | undefined
- let runInBackground = false
- let completed = false
- let result: string = ""
- let exitDetails: ExitCodeDetails | undefined
- let shellIntegrationError: string | undefined
- const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
- const clineProvider = await cline.providerRef.deref()
- const callbacks: RooTerminalCallbacks = {
- onLine: async (output: string, process: RooTerminalProcess) => {
- const status: CommandExecutionStatus = { executionId, status: "output", output }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- if (runInBackground) {
- return
- }
- try {
- const { response, text, images } = await cline.ask("command_output", "")
- runInBackground = true
- if (response === "messageResponse") {
- message = { text, images }
- process.continue()
- }
- } catch (_error) {}
- },
- onCompleted: (output: string | undefined) => {
- result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
- cline.say("command_output", result)
- completed = true
- },
- onShellExecutionStarted: (pid: number | undefined) => {
- console.log(`[executeCommand] onShellExecutionStarted: ${pid}`)
- const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- },
- onShellExecutionComplete: (details: ExitCodeDetails) => {
- const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- exitDetails = details
- },
- }
- if (terminalProvider === "vscode") {
- callbacks.onNoShellIntegration = async (error: string) => {
- telemetryService.captureShellIntegrationError(cline.taskId)
- shellIntegrationError = error
- }
- }
- const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, cline.taskId, terminalProvider)
- if (terminal instanceof Terminal) {
- terminal.terminal.show()
- // Update the working directory in case the terminal we asked for has
- // a different working directory so that the model will know where the
- // command actually executed.
- workingDir = terminal.getCurrentWorkingDirectory()
- }
- const process = terminal.runCommand(command, callbacks)
- cline.terminalProcess = process
- await process
- cline.terminalProcess = undefined
- if (shellIntegrationError) {
- throw new ShellIntegrationError(shellIntegrationError)
- }
- // Wait for a short delay to ensure all messages are sent to the webview.
- // This delay allows time for non-awaited promises to be created and
- // for their associated messages to be sent to the webview, maintaining
- // the correct order of messages (although the webview is smart about
- // grouping command_output messages despite any gaps anyways).
- await delay(50)
- if (message) {
- const { text, images } = message
- await cline.say("user_feedback", text, images)
- return [
- true,
- formatResponse.toolResult(
- [
- `Command is still running in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`,
- result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
- `The user provided the following feedback:`,
- `<feedback>\n${text}\n</feedback>`,
- ].join("\n"),
- images,
- ),
- ]
- } else if (completed || exitDetails) {
- let exitStatus: string = ""
- if (exitDetails !== undefined) {
- if (exitDetails.signalName) {
- exitStatus = `Process terminated by signal ${exitDetails.signalName}`
- if (exitDetails.coreDumpPossible) {
- exitStatus += " - core dump possible"
- }
- } else if (exitDetails.exitCode === undefined) {
- result += "<VSCE exit code is undefined: terminal output and command execution status is unknown.>"
- exitStatus = `Exit code: <undefined, notify user>`
- } else {
- if (exitDetails.exitCode !== 0) {
- exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n"
- }
- exitStatus += `Exit code: ${exitDetails.exitCode}`
- }
- } else {
- result += "<VSCE exitDetails == undefined: terminal output and command execution status is unknown.>"
- exitStatus = `Exit code: <undefined, notify user>`
- }
- let workingDirInfo = ` within working directory '${workingDir.toPosix()}'`
- const newWorkingDir = terminal.getCurrentWorkingDirectory()
- if (newWorkingDir !== workingDir) {
- workingDirInfo += `\nNOTICE: Your command changed the working directory for this terminal to '${newWorkingDir.toPosix()}' so you MUST adjust future commands accordingly because they will be executed in this directory`
- }
- return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`]
- } else {
- return [
- false,
- [
- `Command is still running in terminal ${workingDir ? ` from '${workingDir.toPosix()}'` : ""}.`,
- result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
- "You will be updated on the terminal status and new output in the future.",
- ].join("\n"),
- ]
- }
- }
|