1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::process::Command;
4
5use super::Tool;
6
7const MAX_OUTPUT_CHARS: usize = 10_000;
8
9pub struct RunCommandTool;
10
11impl Tool for RunCommandTool {
12 fn name(&self) -> &str {
13 "run_command"
14 }
15
16 fn description(&self) -> &str {
17 "Execute a shell command and return its output. Use this to run build commands, tests, git operations, etc."
18 }
19
20 fn input_schema(&self) -> Value {
21 serde_json::json!({
22 "type": "object",
23 "properties": {
24 "command": {
25 "type": "string",
26 "description": "The shell command to execute"
27 },
28 "working_directory": {
29 "type": "string",
30 "description": "Optional working directory for the command"
31 }
32 },
33 "required": ["command"]
34 })
35 }
36
37 fn execute(&self, input: Value) -> Result<String> {
38 let command = input["command"]
39 .as_str()
40 .context("Missing required parameter 'command'")?;
41 let working_dir = input["working_directory"].as_str();
42
43 tracing::debug!("run_command: {}", command);
44
45 let mut cmd = Command::new("/bin/sh");
46 cmd.arg("-c").arg(command);
47
48 if let Some(dir) = working_dir {
49 cmd.current_dir(dir);
50 }
51
52 let output = cmd
53 .output()
54 .with_context(|| format!("Failed to execute command: {}", command))?;
55
56 let stdout = String::from_utf8_lossy(&output.stdout);
57 let stderr = String::from_utf8_lossy(&output.stderr);
58 let exit_code = output.status.code().unwrap_or(-1);
59
60 let mut combined = format!("Exit code: {}\n", exit_code);
61
62 if !stdout.is_empty() {
63 combined.push_str(&stdout);
64 }
65
66 if !stderr.is_empty() {
67 if !stdout.is_empty() {
68 combined.push('\n');
69 }
70 combined.push_str("STDERR:\n");
71 combined.push_str(&stderr);
72 }
73
74 if combined.len() > MAX_OUTPUT_CHARS {
75 combined.truncate(MAX_OUTPUT_CHARS);
76 combined.push_str(&format!(
77 "\n... (output truncated at {} chars)",
78 MAX_OUTPUT_CHARS
79 ));
80 }
81
82 Ok(combined)
83 }
84}