collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use tokio::process::Command;
use tokio::time::{Duration, timeout};

use crate::common::Result;

/// Maximum stdout bytes to return (prevents overwhelming the LLM context).
const MAX_STDOUT_BYTES: usize = 10_000;
/// Maximum stderr bytes to return.
const MAX_STDERR_BYTES: usize = 5_000;

#[derive(Debug, Deserialize)]
pub struct BashInput {
    pub command: String,
    #[serde(default = "default_timeout")]
    pub timeout_secs: u64,
}

fn default_timeout() -> u64 {
    120
}

#[derive(Debug, Serialize)]
pub struct BashOutput {
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
}

pub fn definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "bash",
            "description": "Execute a bash command. Use for running shell commands, git operations, builds, tests, etc.",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "The bash command to execute"
                    },
                    "timeout_secs": {
                        "type": "integer",
                        "description": "Timeout in seconds (default: 120)"
                    }
                },
                "required": ["command"]
            }
        }
    })
}

pub async fn execute(input: BashInput, working_dir: &str) -> Result<String> {
    let duration = Duration::from_secs(input.timeout_secs);

    let result = timeout(duration, async {
        Command::new("bash")
            .arg("-c")
            .arg(&input.command)
            .current_dir(working_dir)
            // Isolate subprocess from TUI's terminal file descriptors.
            // stdin=null prevents reads from the controlling terminal;
            // stdout/stderr are auto-piped by .output().
            .stdin(Stdio::null())
            .output()
            .await
    })
    .await;

    match result {
        Ok(Ok(output)) => {
            let stdout = String::from_utf8_lossy(&output.stdout);
            let stderr = String::from_utf8_lossy(&output.stderr);
            let exit_code = output.status.code().unwrap_or(-1);

            let result = BashOutput {
                exit_code,
                stdout: truncate_output(&stdout, MAX_STDOUT_BYTES),
                stderr: truncate_output(&stderr, MAX_STDERR_BYTES),
            };

            Ok(serde_json::to_string_pretty(&result)?)
        }
        Ok(Err(e)) => Err(crate::common::AgentError::Command(format!(
            "Failed to execute command: {}",
            e
        ))),
        Err(_) => Err(crate::common::AgentError::Timeout(input.timeout_secs)),
    }
}

fn truncate_output(s: &str, max_len: usize) -> String {
    if s.len() <= max_len {
        s.to_string()
    } else {
        format!(
            "{}...\n[truncated, showing first {max_len} of {} bytes]",
            crate::util::truncate_bytes(s, max_len),
            s.len()
        )
    }
}