1use anyhow::{anyhow, Context as _, Result};
2use assistant_tool::{ActionLog, Tool};
3use gpui::{App, Entity, Task};
4use language_model::LanguageModelRequestMessage;
5use project::Project;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::sync::Arc;
10use ui::IconName;
11use util::command::new_smol_command;
12use util::markdown::MarkdownString;
13
14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct BashToolInput {
16 /// The bash command to execute as a one-liner.
17 command: String,
18 /// Working directory for the command. This must be one of the root directories of the project.
19 cd: String,
20}
21
22pub struct BashTool;
23
24impl Tool for BashTool {
25 fn name(&self) -> String {
26 "bash".to_string()
27 }
28
29 fn needs_confirmation(&self) -> bool {
30 true
31 }
32
33 fn description(&self) -> String {
34 include_str!("./bash_tool/description.md").to_string()
35 }
36
37 fn icon(&self) -> IconName {
38 IconName::Terminal
39 }
40
41 fn input_schema(&self) -> serde_json::Value {
42 let schema = schemars::schema_for!(BashToolInput);
43 serde_json::to_value(&schema).unwrap()
44 }
45
46 fn ui_text(&self, input: &serde_json::Value) -> String {
47 match serde_json::from_value::<BashToolInput>(input.clone()) {
48 Ok(input) => {
49 if input.command.contains('\n') {
50 MarkdownString::code_block("bash", &input.command).0
51 } else {
52 MarkdownString::inline_code(&input.command).0
53 }
54 }
55 Err(_) => "Run bash command".to_string(),
56 }
57 }
58
59 fn run(
60 self: Arc<Self>,
61 input: serde_json::Value,
62 _messages: &[LanguageModelRequestMessage],
63 project: Entity<Project>,
64 _action_log: Entity<ActionLog>,
65 cx: &mut App,
66 ) -> Task<Result<String>> {
67 let input: BashToolInput = match serde_json::from_value(input) {
68 Ok(input) => input,
69 Err(err) => return Task::ready(Err(anyhow!(err))),
70 };
71
72 let project = project.read(cx);
73 let input_path = Path::new(&input.cd);
74 let working_dir = if input.cd == "." {
75 // Accept "." as meaning "the one worktree" if we only have one worktree.
76 let mut worktrees = project.worktrees(cx);
77
78 let only_worktree = match worktrees.next() {
79 Some(worktree) => worktree,
80 None => return Task::ready(Err(anyhow!("No worktrees found in the project"))),
81 };
82
83 if worktrees.next().is_some() {
84 return Task::ready(Err(anyhow!("'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.")));
85 }
86
87 only_worktree.read(cx).abs_path()
88 } else if input_path.is_absolute() {
89 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
90 if !project
91 .worktrees(cx)
92 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
93 {
94 return Task::ready(Err(anyhow!(
95 "The absolute path must be within one of the project's worktrees"
96 )));
97 }
98
99 input_path.into()
100 } else {
101 let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
102 return Task::ready(Err(anyhow!(
103 "`cd` directory {} not found in the project",
104 &input.cd
105 )));
106 };
107
108 worktree.read(cx).abs_path()
109 };
110
111 cx.spawn(async move |_| {
112 // Add 2>&1 to merge stderr into stdout for proper interleaving.
113 let command = format!("({}) 2>&1", input.command);
114
115 let output = new_smol_command("bash")
116 .arg("-c")
117 .arg(&command)
118 .current_dir(working_dir)
119 .output()
120 .await
121 .context("Failed to execute bash command")?;
122
123 let output_string = String::from_utf8_lossy(&output.stdout).to_string();
124
125 if output.status.success() {
126 if output_string.is_empty() {
127 Ok("Command executed successfully.".to_string())
128 } else {
129 Ok(output_string)
130 }
131 } else {
132 Ok(format!(
133 "Command failed with exit code {}\n{}",
134 output.status.code().unwrap_or(-1),
135 &output_string
136 ))
137 }
138 })
139 }
140}