1use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::Value;
6use std::path::PathBuf;
7use tokio::process::Command;
8use tokio::time::{timeout, Duration};
9use encoding_rs::GBK;
10
11use super::{Tool, ToolContext, ToolResult};
12use crate::error::Result;
13
14const DEFAULT_TIMEOUT_MS: u64 = 120_000;
16
17pub struct BashTool {
18 max_output_bytes: usize,
20}
21
22#[derive(Debug, Deserialize)]
23struct BashArgs {
24 command: String,
25 #[serde(default)]
26 timeout: Option<u64>,
27 #[serde(default)]
28 working_dir: Option<String>,
29}
30
31impl BashTool {
32 pub fn new(max_output_bytes: usize) -> Self {
33 Self { max_output_bytes }
34 }
35}
36
37#[async_trait]
38impl Tool for BashTool {
39 fn name(&self) -> &str {
40 "bash"
41 }
42
43 fn description(&self) -> &str {
44 "Execute shell commands. Uses cmd.exe on Windows, sh on Linux/macOS. Avoid cd, use absolute paths."
45 }
46
47 fn parameters_schema(&self) -> Value {
48 serde_json::json!({
49 "type": "object",
50 "properties": {
51 "command": {
52 "type": "string",
53 "description": "The command to execute"
54 },
55 "timeout": {
56 "type": "integer",
57 "description": "Timeout in milliseconds, default 120000"
58 },
59 "working_dir": {
60 "type": "string",
61 "description": "Working directory (optional, defaults to project root)"
62 }
63 },
64 "required": ["command"]
65 })
66 }
67
68 fn requires_confirmation(&self) -> bool {
69 true
70 }
71
72 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
73 let parsed: BashArgs = match serde_json::from_value(args) {
74 Ok(a) => a,
75 Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
76 };
77
78 let work_dir = parsed
79 .working_dir
80 .map(PathBuf::from)
81 .unwrap_or_else(|| ctx.working_dir.clone());
82
83 let timeout_ms = parsed.timeout.unwrap_or(DEFAULT_TIMEOUT_MS);
84
85 let mut cmd = build_shell_command(&parsed.command);
87 cmd.current_dir(&work_dir);
88
89 let result = timeout(Duration::from_millis(timeout_ms), cmd.output()).await;
91
92 match result {
93 Ok(Ok(output)) => {
94 let stdout = decode_output(&output.stdout);
96 let stderr = decode_output(&output.stderr);
97 let exit_code = output.status.code().unwrap_or(-1);
98
99 let mut content = String::new();
100
101 if !stdout.is_empty() {
103 content.push_str(&truncate_output(&stdout, self.max_output_bytes));
104 }
105
106 if !stderr.is_empty() {
108 if !content.is_empty() {
109 content.push_str("\n");
110 }
111 content.push_str("[stderr]\n");
112 content.push_str(&truncate_output(&stderr, self.max_output_bytes));
113 }
114
115 if exit_code != 0 {
117 if !content.is_empty() {
118 content.push_str("\n");
119 }
120 content.push_str(&format!("[exit code: {}]", exit_code));
121 }
122
123 if content.is_empty() {
124 content = "(Command executed successfully, no output)".to_string();
125 }
126
127 Ok(ToolResult {
128 content,
129 is_error: exit_code != 0,
130 })
131 }
132 Ok(Err(e)) => Ok(ToolResult::error(format!("Command execution failed: {}", e))),
133 Err(_) => Ok(ToolResult::error(format!(
134 "Command timed out ({}ms limit)",
135 timeout_ms
136 ))),
137 }
138 }
139}
140
141fn build_shell_command(command: &str) -> Command {
143 if cfg!(target_os = "windows") {
144 let mut cmd = Command::new("cmd");
145 cmd.args(["/C", command]);
146 cmd
147 } else {
148 let mut cmd = Command::new("sh");
149 cmd.args(["-c", command]);
150 cmd
151 }
152}
153
154fn decode_output(bytes: &[u8]) -> String {
156 #[cfg(target_os = "windows")]
157 {
158 let (cow, _, has_error) = GBK.decode(bytes);
160 if !has_error {
161 return cow.to_string();
162 }
163 }
164 String::from_utf8_lossy(bytes).to_string()
166}
167
168fn truncate_output(output: &str, max_bytes: usize) -> String {
170 if output.len() <= max_bytes {
171 output.to_string()
172 } else {
173 let truncated: String = output.chars().take(max_bytes).collect();
174 format!(
175 "{}\n... (Output truncated, {} bytes total, showing first {} bytes)",
176 truncated,
177 output.len(),
178 max_bytes
179 )
180 }
181}