Files
cassady/src/tools/shell.rs
T

132 lines
4.2 KiB
Rust

use super::{schema, ToolContext, ToolRuntimeEvent, ToolSpec};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt};
#[derive(Debug, Deserialize)]
struct Args {
command: String,
#[serde(default)]
timeout: Option<u64>,
}
pub fn spec() -> ToolSpec {
ToolSpec {
name: "shell".into(),
description: "Run a shell command in the launch cwd. Request this tool directly when shell is useful; do not ask the user for permission in chat. Cass may show a separate approval UI before execution depending on the active access mode. Streams stdout/stderr while running, then returns stdout, stderr, and exit code. Use timeout (seconds) to limit runtime."
.into(),
parameters: schema::object(
json!({
"command": {"type": "string", "description": "Shell command to execute"},
"timeout": {"type": "integer", "description": "Optional timeout in seconds (default 30)"}
}),
&["command"],
),
}
}
pub async fn run(args: Value, ctx: &ToolContext) -> Result<String> {
let args: Args = serde_json::from_value(args)?;
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg(&args.command);
cmd.current_dir(&ctx.cwd);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd.spawn().context("spawning shell command")?;
let stdout = child.stdout.take().context("capturing command stdout")?;
let stderr = child.stderr.take().context("capturing command stderr")?;
let stdout_tx = ctx.runtime_tx.clone();
let stderr_tx = ctx.runtime_tx.clone();
let stdout_task = tokio::spawn(read_stream("stdout", stdout, stdout_tx));
let stderr_task = tokio::spawn(read_stream("stderr", stderr, stderr_tx));
let timeout = Duration::from_secs(args.timeout.unwrap_or(30));
let mut timed_out = false;
let status = match tokio::time::timeout(timeout, child.wait()).await {
Ok(status) => status.context("waiting for shell command")?,
Err(_) => {
timed_out = true;
let _ = child.kill().await;
child
.wait()
.await
.context("waiting for timed-out shell command to exit")?
}
};
let stdout = stdout_task
.await
.context("joining stdout reader")?
.context("reading command stdout")?;
let stderr = stderr_task
.await
.context("joining stderr reader")?
.context("reading command stderr")?;
let result = format_result(&stdout, &stderr, status.code().unwrap_or(-1));
if timed_out {
bail!("command timed out after {}s\n{}", timeout.as_secs(), result);
}
Ok(result)
}
async fn read_stream<R>(
stream: &'static str,
mut reader: R,
tx: Option<tokio::sync::mpsc::UnboundedSender<ToolRuntimeEvent>>,
) -> Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut collected = Vec::new();
let mut buf = [0_u8; 4096];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
break;
}
let chunk = &buf[..n];
collected.extend_from_slice(chunk);
if let Some(tx) = &tx {
let _ = tx.send(ToolRuntimeEvent::OutputChunk {
stream: stream.to_string(),
content: String::from_utf8_lossy(chunk).to_string(),
});
}
}
Ok(collected)
}
fn format_result(stdout: &[u8], stderr: &[u8], code: i32) -> String {
let stdout = String::from_utf8_lossy(stdout);
let stderr = String::from_utf8_lossy(stderr);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str("stdout:\n");
result.push_str(&stdout);
if !stdout.ends_with('\n') {
result.push('\n');
}
}
if !stderr.is_empty() {
result.push_str("stderr:\n");
result.push_str(&stderr);
if !stderr.ends_with('\n') {
result.push('\n');
}
}
if result.is_empty() {
result.push_str("(no output)\n");
}
result.push_str(&format!("exit code: {}\n", code));
result
}