use crate::tool::Tool;
use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
use tokio::process::Command;
const SHELL_TIMEOUT_SECS: u64 = 60;
const MAX_OUTPUT_BYTES: usize = 1_048_576;
pub struct ShellTool;
impl ShellTool {
pub fn new() -> Self {
Self
}
}
impl Default for ShellTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for ShellTool {
fn name(&self) -> &str {
"shell"
}
fn description(&self) -> &str {
"Execute a shell command in the workspace directory"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 60)",
"minimum": 1,
"maximum": 300
}
},
"required": ["command"]
})
}
fn requires_network(&self) -> bool {
false }
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<serde_json::Value> {
let command = args
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'command' parameter"))?;
let timeout_secs = args
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(SHELL_TIMEOUT_SECS);
let timeout = Duration::from_secs(timeout_secs.min(300));
let output =
tokio::time::timeout(timeout, Command::new("sh").arg("-c").arg(command).output())
.await
.map_err(|_| {
anyhow::anyhow!("Command timed out after {} seconds", timeout_secs)
})??;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = if stdout.len() > MAX_OUTPUT_BYTES {
format!("{}... [truncated]", &stdout[..MAX_OUTPUT_BYTES])
} else {
stdout.to_string()
};
let stderr = if stderr.len() > MAX_OUTPUT_BYTES {
format!("{}... [truncated]", &stderr[..MAX_OUTPUT_BYTES])
} else {
stderr.to_string()
};
Ok(json!({
"success": output.status.success(),
"stdout": stdout,
"stderr": stderr,
"exit_code": output.status.code(),
"command": command
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_shell_echo() {
let tool = ShellTool::new();
let result = tool
.execute(json!({
"command": "echo 'Hello World'"
}))
.await
.unwrap();
assert_eq!(result["success"], true);
assert!(result["stdout"].as_str().unwrap().contains("Hello World"));
}
#[tokio::test]
async fn test_shell_error() {
let tool = ShellTool::new();
let result = tool
.execute(json!({
"command": "exit 1"
}))
.await
.unwrap();
assert_eq!(result["success"], false);
assert_eq!(result["exit_code"], 1);
}
#[tokio::test]
async fn test_shell_timeout() {
let tool = ShellTool::new();
let result = tool
.execute(json!({
"command": "sleep 10",
"timeout": 1
}))
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("timed out"));
}
}