oy-cli 0.8.7

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::Value;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt as _;
use tokio::process::Command;
use tokio::time::timeout;

use crate::config;

use super::ToolContext;
use super::args::BashArgs;
use super::policy::require_mutation_approval;

const MAX_BASH_TIMEOUT_SECONDS: u64 = 600;
const MAX_BASH_OUTPUT_BYTES: usize = 200_000;

#[derive(Debug, Serialize)]
pub(super) struct BashOutput {
    pub command: String,
    pub returncode: i32,
    pub stdout: String,
    pub stderr: String,
    pub stdout_preview: String,
    pub stderr_preview: String,
    pub stdout_truncated: bool,
    pub stderr_truncated: bool,
    pub stdout_capped: bool,
    pub stderr_capped: bool,
}

pub(crate) async fn tool_bash(ctx: &ToolContext, args: BashArgs) -> Result<Value> {
    if args.command.len() > config::max_bash_cmd_bytes() {
        bail!("command too large ({} bytes)", args.command.len());
    }
    let timeout_seconds = args.timeout_seconds.clamp(1, MAX_BASH_TIMEOUT_SECONDS);
    let approval_preview = format!(
        "workspace: {}\ntimeout: {timeout_seconds}s\ncommand:\n{}",
        ctx.root.display(),
        args.command.trim()
    );
    require_mutation_approval(ctx, "bash", Some(&approval_preview))?;
    let mut child = Command::new("bash")
        .arg("-c")
        .arg(&args.command)
        .current_dir(&ctx.root)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn()?;
    let stdout = child.stdout.take().context("failed to capture stdout")?;
    let stderr = child.stderr.take().context("failed to capture stderr")?;
    let stdout_task = tokio::spawn(read_child_output(stdout, MAX_BASH_OUTPUT_BYTES));
    let stderr_task = tokio::spawn(read_child_output(stderr, MAX_BASH_OUTPUT_BYTES));
    let status = match timeout(Duration::from_secs(timeout_seconds), child.wait()).await {
        Ok(status) => status?,
        Err(_) => {
            let _ = child.kill().await;
            let _ = child.wait().await;
            bail!("bash timed out after {timeout_seconds}s");
        }
    };
    let (stdout, stdout_truncated) = stdout_task.await??;
    let (stderr, stderr_truncated) = stderr_task.await??;
    let (stdout_preview, stdout_preview_truncated) = crate::ui::head_tail(&stdout, 12_000);
    let (stderr_preview, stderr_preview_truncated) = crate::ui::head_tail(&stderr, 8_000);
    Ok(serde_json::to_value(BashOutput {
        command: args.command,
        returncode: status.code().unwrap_or(-1),
        stdout,
        stderr,
        stdout_preview,
        stderr_preview,
        stdout_truncated: stdout_truncated || stdout_preview_truncated,
        stderr_truncated: stderr_truncated || stderr_preview_truncated,
        stdout_capped: stdout_truncated,
        stderr_capped: stderr_truncated,
    })?)
}

async fn read_child_output<R>(mut reader: R, max_bytes: usize) -> Result<(String, bool)>
where
    R: tokio::io::AsyncRead + Unpin,
{
    let mut out = Vec::new();
    let mut truncated = false;
    let mut buf = [0u8; 1024];
    loop {
        let n = reader.read(&mut buf).await?;
        if n == 0 {
            break;
        }
        let remaining = max_bytes.saturating_sub(out.len());
        if n > remaining {
            out.extend_from_slice(&buf[..remaining]);
            truncated = true;
        } else if remaining > 0 {
            out.extend_from_slice(&buf[..n]);
        } else {
            truncated = true;
        }
    }
    Ok((String::from_utf8_lossy(&out).to_string(), truncated))
}