executeCommandTool.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import fs from "fs/promises"
  2. import * as path from "path"
  3. import delay from "delay"
  4. import { Cline } from "../Cline"
  5. import { CommandExecutionStatus } from "../../schemas"
  6. import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools"
  7. import { formatResponse } from "../prompts/responses"
  8. import { unescapeHtmlEntities } from "../../utils/text-normalization"
  9. import { telemetryService } from "../../services/telemetry/TelemetryService"
  10. import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
  11. import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
  12. import { Terminal } from "../../integrations/terminal/Terminal"
  13. class ShellIntegrationError extends Error {}
  14. export async function executeCommandTool(
  15. cline: Cline,
  16. block: ToolUse,
  17. askApproval: AskApproval,
  18. handleError: HandleError,
  19. pushToolResult: PushToolResult,
  20. removeClosingTag: RemoveClosingTag,
  21. ) {
  22. let command: string | undefined = block.params.command
  23. const customCwd: string | undefined = block.params.cwd
  24. try {
  25. if (block.partial) {
  26. await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
  27. return
  28. } else {
  29. if (!command) {
  30. cline.consecutiveMistakeCount++
  31. cline.recordToolError("execute_command")
  32. pushToolResult(await cline.sayAndCreateMissingParamError("execute_command", "command"))
  33. return
  34. }
  35. const ignoredFileAttemptedToAccess = cline.rooIgnoreController?.validateCommand(command)
  36. if (ignoredFileAttemptedToAccess) {
  37. await cline.say("rooignore_error", ignoredFileAttemptedToAccess)
  38. pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess)))
  39. return
  40. }
  41. cline.consecutiveMistakeCount = 0
  42. const executionId = Date.now().toString()
  43. command = unescapeHtmlEntities(command) // Unescape HTML entities.
  44. const didApprove = await askApproval("command", command, { id: executionId })
  45. if (!didApprove) {
  46. return
  47. }
  48. const clineProvider = await cline.providerRef.deref()
  49. const clineProviderState = await clineProvider?.getState()
  50. const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
  51. const options: ExecuteCommandOptions = {
  52. executionId,
  53. command,
  54. customCwd,
  55. terminalShellIntegrationDisabled,
  56. terminalOutputLineLimit,
  57. }
  58. try {
  59. const [rejected, result] = await executeCommand(cline, options)
  60. if (rejected) {
  61. cline.didRejectTool = true
  62. }
  63. pushToolResult(result)
  64. } catch (error: unknown) {
  65. const status: CommandExecutionStatus = { executionId, status: "fallback" }
  66. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  67. await cline.say("shell_integration_warning")
  68. if (error instanceof ShellIntegrationError) {
  69. const [rejected, result] = await executeCommand(cline, {
  70. ...options,
  71. terminalShellIntegrationDisabled: true,
  72. })
  73. if (rejected) {
  74. cline.didRejectTool = true
  75. }
  76. pushToolResult(result)
  77. } else {
  78. pushToolResult(`Command failed to execute in terminal due to a shell integration error.`)
  79. }
  80. }
  81. return
  82. }
  83. } catch (error) {
  84. await handleError("executing command", error)
  85. return
  86. }
  87. }
  88. export type ExecuteCommandOptions = {
  89. executionId: string
  90. command: string
  91. customCwd?: string
  92. terminalShellIntegrationDisabled?: boolean
  93. terminalOutputLineLimit?: number
  94. }
  95. export async function executeCommand(
  96. cline: Cline,
  97. {
  98. executionId,
  99. command,
  100. customCwd,
  101. terminalShellIntegrationDisabled = false,
  102. terminalOutputLineLimit = 500,
  103. }: ExecuteCommandOptions,
  104. ): Promise<[boolean, ToolResponse]> {
  105. let workingDir: string
  106. if (!customCwd) {
  107. workingDir = cline.cwd
  108. } else if (path.isAbsolute(customCwd)) {
  109. workingDir = customCwd
  110. } else {
  111. workingDir = path.resolve(cline.cwd, customCwd)
  112. }
  113. try {
  114. await fs.access(workingDir)
  115. } catch (error) {
  116. return [false, `Working directory '${workingDir}' does not exist.`]
  117. }
  118. let message: { text?: string; images?: string[] } | undefined
  119. let runInBackground = false
  120. let completed = false
  121. let result: string = ""
  122. let exitDetails: ExitCodeDetails | undefined
  123. let shellIntegrationError: string | undefined
  124. const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
  125. const clineProvider = await cline.providerRef.deref()
  126. const callbacks: RooTerminalCallbacks = {
  127. onLine: async (output: string, process: RooTerminalProcess) => {
  128. const status: CommandExecutionStatus = { executionId, status: "output", output }
  129. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  130. if (runInBackground) {
  131. return
  132. }
  133. try {
  134. const { response, text, images } = await cline.ask("command_output", "")
  135. runInBackground = true
  136. if (response === "messageResponse") {
  137. message = { text, images }
  138. process.continue()
  139. }
  140. } catch (_error) {}
  141. },
  142. onCompleted: (output: string | undefined) => {
  143. result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
  144. cline.say("command_output", result)
  145. completed = true
  146. },
  147. onShellExecutionStarted: (pid: number | undefined) => {
  148. console.log(`[executeCommand] onShellExecutionStarted: ${pid}`)
  149. const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
  150. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  151. },
  152. onShellExecutionComplete: (details: ExitCodeDetails) => {
  153. const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
  154. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  155. exitDetails = details
  156. },
  157. }
  158. if (terminalProvider === "vscode") {
  159. callbacks.onNoShellIntegration = async (error: string) => {
  160. telemetryService.captureShellIntegrationError(cline.taskId)
  161. shellIntegrationError = error
  162. }
  163. }
  164. const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, cline.taskId, terminalProvider)
  165. if (terminal instanceof Terminal) {
  166. terminal.terminal.show()
  167. // Update the working directory in case the terminal we asked for has
  168. // a different working directory so that the model will know where the
  169. // command actually executed.
  170. workingDir = terminal.getCurrentWorkingDirectory()
  171. }
  172. const process = terminal.runCommand(command, callbacks)
  173. cline.terminalProcess = process
  174. await process
  175. cline.terminalProcess = undefined
  176. if (shellIntegrationError) {
  177. throw new ShellIntegrationError(shellIntegrationError)
  178. }
  179. // Wait for a short delay to ensure all messages are sent to the webview.
  180. // This delay allows time for non-awaited promises to be created and
  181. // for their associated messages to be sent to the webview, maintaining
  182. // the correct order of messages (although the webview is smart about
  183. // grouping command_output messages despite any gaps anyways).
  184. await delay(50)
  185. if (message) {
  186. const { text, images } = message
  187. await cline.say("user_feedback", text, images)
  188. return [
  189. true,
  190. formatResponse.toolResult(
  191. [
  192. `Command is still running in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`,
  193. result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
  194. `The user provided the following feedback:`,
  195. `<feedback>\n${text}\n</feedback>`,
  196. ].join("\n"),
  197. images,
  198. ),
  199. ]
  200. } else if (completed || exitDetails) {
  201. let exitStatus: string = ""
  202. if (exitDetails !== undefined) {
  203. if (exitDetails.signalName) {
  204. exitStatus = `Process terminated by signal ${exitDetails.signalName}`
  205. if (exitDetails.coreDumpPossible) {
  206. exitStatus += " - core dump possible"
  207. }
  208. } else if (exitDetails.exitCode === undefined) {
  209. result += "<VSCE exit code is undefined: terminal output and command execution status is unknown.>"
  210. exitStatus = `Exit code: <undefined, notify user>`
  211. } else {
  212. if (exitDetails.exitCode !== 0) {
  213. exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n"
  214. }
  215. exitStatus += `Exit code: ${exitDetails.exitCode}`
  216. }
  217. } else {
  218. result += "<VSCE exitDetails == undefined: terminal output and command execution status is unknown.>"
  219. exitStatus = `Exit code: <undefined, notify user>`
  220. }
  221. let workingDirInfo = ` within working directory '${workingDir.toPosix()}'`
  222. const newWorkingDir = terminal.getCurrentWorkingDirectory()
  223. if (newWorkingDir !== workingDir) {
  224. 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`
  225. }
  226. return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`]
  227. } else {
  228. return [
  229. false,
  230. [
  231. `Command is still running in terminal ${workingDir ? ` from '${workingDir.toPosix()}'` : ""}.`,
  232. result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
  233. "You will be updated on the terminal status and new output in the future.",
  234. ].join("\n"),
  235. ]
  236. }
  237. }