use std::{path::Path, process::Stdio, time::Duration};
use anyhow::{Context, Result, bail};
use serde_json::Value;
use tokio::{io::AsyncWriteExt, process::Command, time};
use tracing::{debug, warn};
use super::manifest::ToolSpec;
const MAX_OUTPUT_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Default)]
pub struct RunOptions {
pub env: Vec<(String, String)>,
}
pub async fn run_tool(
spec: &ToolSpec,
skill_dir: &Path,
input: Value,
opts: &RunOptions,
) -> Result<Value> {
let timeout = Duration::from_secs(u64::from(spec.timeout_seconds));
let result = time::timeout(timeout, do_run(spec, skill_dir, &input, opts)).await;
match result {
Ok(inner) => inner,
Err(_elapsed) => bail!(
"tool `{}` timed out after {}s",
spec.name,
spec.timeout_seconds
),
}
}
async fn do_run(
spec: &ToolSpec,
skill_dir: &Path,
input: &Value,
opts: &RunOptions,
) -> Result<Value> {
let parts = shell_split(&spec.command)
.with_context(|| format!("cannot parse command: {:?}", spec.command))?;
let (program, args) = parts.split_first().context("command is empty")?;
if Path::new(program).is_absolute() {
warn!(command = program, "skill tool uses absolute path");
}
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(skill_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for (k, v) in &opts.env {
cmd.env(k, v);
}
if !spec.stdin_json
&& let Value::Object(map) = input
{
for (k, v) in map {
cmd.arg(format!("--{k}"));
cmd.arg(json_to_arg(v));
}
}
debug!(
tool = %spec.name,
command = %spec.command,
"running skill tool"
);
let mut child = cmd
.spawn()
.with_context(|| format!("failed to spawn `{}`", spec.command))?;
if spec.stdin_json
&& let Some(mut stdin) = child.stdin.take()
{
let payload = serde_json::to_vec(input)?;
stdin
.write_all(&payload)
.await
.context("write to child stdin")?;
}
let output = child
.wait_with_output()
.await
.context("wait for child process")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"tool `{}` exited with status {}: {}",
spec.name,
output.status,
stderr.trim()
);
}
let stdout_bytes = if output.stdout.len() > MAX_OUTPUT_BYTES {
warn!(
tool = %spec.name,
bytes = output.stdout.len(),
limit = MAX_OUTPUT_BYTES,
"tool output truncated"
);
&output.stdout[..MAX_OUTPUT_BYTES]
} else {
&output.stdout
};
let result = if stdout_bytes.is_empty() {
Value::Null
} else if let Ok(v) = serde_json::from_slice(stdout_bytes) {
v
} else {
Value::String(String::from_utf8_lossy(stdout_bytes).into_owned())
};
Ok(result)
}
fn shell_split(s: &str) -> Result<Vec<String>> {
let mut parts = Vec::new();
let mut current = String::new();
let mut chars = s.chars().peekable();
let mut in_single = false;
let mut in_double = false;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
' ' | '\t' if !in_single && !in_double => {
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
}
'\\' if in_double => {
if let Some(next) = chars.next() {
current.push(next);
}
}
other => current.push(other),
}
}
if in_single || in_double {
bail!("unterminated quote in command: {s:?}");
}
if !current.is_empty() {
parts.push(current);
}
Ok(parts)
}
fn json_to_arg(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_split_simple() {
assert_eq!(
shell_split("./run.sh --foo bar").unwrap(),
vec!["./run.sh", "--foo", "bar"]
);
}
#[test]
fn shell_split_quoted() {
assert_eq!(
shell_split(r#"./run.sh "hello world""#).unwrap(),
vec!["./run.sh", "hello world"]
);
}
#[test]
fn shell_split_single_quoted() {
assert_eq!(
shell_split("./run.sh 'foo bar'").unwrap(),
vec!["./run.sh", "foo bar"]
);
}
#[tokio::test]
async fn run_echo_tool() {
if which::which("echo").is_err() {
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let script = tmp.path().join("echo_tool.sh");
std::fs::write(&script, "#!/bin/sh\nread INPUT\necho \"\\\"$INPUT\\\"\"")
.expect("write script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script).expect("meta").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).expect("chmod");
}
let spec = ToolSpec {
name: "echo_test".into(),
description: "echo test".into(),
command: "./echo_tool.sh".into(),
input_schema: None,
timeout_seconds: 5,
stdin_json: false, };
let result = run_tool(&spec, tmp.path(), Value::Null, &RunOptions::default()).await;
let _ = result; }
}