rho-coding-agent 0.9.3

A lightweight agent harness inspired by Pi
use crate::tool::*;
use serde::Deserialize;
use serde_json::json;
use tokio::process::Command;

pub struct PowerShell;

#[derive(Deserialize)]
struct Args {
    command: String,
    timeout_seconds: Option<u64>,
}

#[async_trait::async_trait]
impl Tool for PowerShell {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "powershell".into(),
            description: "Runs a PowerShell command in the current working directory.".into(),
            input_schema: json!({"type":"object","properties":{"command":{"type":"string"},"timeout_seconds":{"type":"integer"}},"required":["command"]}),
        }
    }

    fn display_style(&self) -> ToolDisplayStyle {
        ToolDisplayStyle::file_or_command()
    }

    fn display_command(&self, args: &serde_json::Value) -> Option<String> {
        args.get("command")
            .and_then(|command| command.as_str())
            .map(str::to_string)
    }

    fn display_lines(
        &self,
        args: &serde_json::Value,
        _ctx: &ToolContext,
        result: &ToolResult,
    ) -> Vec<String> {
        let mut lines = vec![match self.display_command(args) {
            Some(command) if !command.trim().is_empty() => format!("powershell {command}"),
            _ => "powershell".into(),
        }];
        if !result.content.trim().is_empty() {
            lines.push(String::new());
            lines.push(result.content.clone());
        }
        lines
    }

    async fn call(
        &self,
        args: serde_json::Value,
        ctx: ToolContext,
        id: String,
    ) -> Result<ToolResult, ToolError> {
        let args: Args = serde_json::from_value(args)?;
        let start = std::time::Instant::now();
        let command = wrapped_command(&args.command);
        let mut child = Command::new("powershell.exe");
        child
            .kill_on_drop(true)
            .arg("-NoProfile")
            .arg("-NonInteractive")
            .arg("-ExecutionPolicy")
            .arg("Bypass")
            .arg("-Command")
            .arg(command)
            .current_dir(&ctx.cwd);
        let fut = child.output();
        let output = if let Some(secs) = args.timeout_seconds {
            tokio::time::timeout(std::time::Duration::from_secs(secs), fut)
                .await
                .map_err(|_| ToolError::Message(format!("command timed out after {secs}s")))??
        } else {
            fut.await?
        };
        let elapsed_secs = start.elapsed().as_secs_f64();
        let exit_code = output
            .status
            .code()
            .map(|c| c.to_string())
            .unwrap_or_else(|| "signal".into());
        let mut content = format!(
            "stdout:\n{}\n\nstderr:\n{}\n\ntime: {:.1}s  exit code: {}",
            String::from_utf8(output.stdout)?,
            String::from_utf8(output.stderr)?,
            elapsed_secs,
            exit_code
        );
        content = truncate(content, ctx.max_output_bytes);
        Ok(ToolResult {
            id,
            ok: output.status.success(),
            content,
        })
    }
}

fn wrapped_command(command: &str) -> String {
    format!(
        "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); \
         $OutputEncoding = [Console]::OutputEncoding; \
         & {{ {command} }}; \
         if ($null -ne $LASTEXITCODE) {{ exit $LASTEXITCODE }}; \
         if (-not $?) {{ exit 1 }}; \
         exit 0"
    )
}