gigi-cli 1.0.1

Gigi — A Claude Code-like AI coding assistant CLI in Rust
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use tokio::process::Command;

use super::{Tool, ToolOutput};

const MAX_OUTPUT_BYTES: usize = 16_384;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BashCommandInput {
    pub command: String,
    pub timeout: Option<u64>,
    pub description: Option<String>,
    #[serde(rename = "run_in_background")]
    pub run_in_background: Option<bool>,
    #[serde(rename = "dangerouslyDisableSandbox")]
    pub dangerously_disable_sandbox: Option<bool>,
    #[serde(rename = "namespaceRestrictions")]
    pub namespace_restrictions: Option<bool>,
    #[serde(rename = "isolateNetwork")]
    pub isolate_network: Option<bool>,
    #[serde(rename = "filesystemMode")]
    pub filesystem_mode: Option<String>,
    #[serde(rename = "allowedMounts")]
    pub allowed_mounts: Option<Vec<String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BashCommandOutput {
    pub stdout: String,
    pub stderr: String,
    #[serde(rename = "rawOutputPath")]
    pub raw_output_path: Option<String>,
    pub interrupted: bool,
    #[serde(rename = "isImage")]
    pub is_image: Option<bool>,
    #[serde(rename = "backgroundTaskId")]
    pub background_task_id: Option<String>,
    #[serde(rename = "backgroundedByUser")]
    pub backgrounded_by_user: Option<bool>,
    #[serde(rename = "assistantAutoBackgrounded")]
    pub assistant_auto_backgrounded: Option<bool>,
    #[serde(rename = "dangerouslyDisableSandbox")]
    pub dangerously_disable_sandbox: Option<bool>,
    #[serde(rename = "returnCodeInterpretation")]
    pub return_code_interpretation: Option<String>,
    #[serde(rename = "noOutputExpected")]
    pub no_output_expected: Option<bool>,
    #[serde(rename = "structuredContent")]
    pub structured_content: Option<Vec<Value>>,
    #[serde(rename = "persistedOutputPath")]
    pub persisted_output_path: Option<String>,
    #[serde(rename = "persistedOutputSize")]
    pub persisted_output_size: Option<u64>,
    #[serde(rename = "sandboxStatus")]
    pub sandbox_status: Option<Value>,
}

fn truncate_output(s: &str) -> String {
    if s.len() <= MAX_OUTPUT_BYTES {
        return s.to_string();
    }
    let mut end = MAX_OUTPUT_BYTES;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    let mut truncated = s[..end].to_string();
    truncated.push_str("\n\n[output truncated — exceeded 16384 bytes]");
    truncated
}

pub struct BashTool;

impl BashTool {
    pub fn new() -> Self {
        Self
    }
}

impl Default for BashTool {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl Tool for BashTool {
    fn name(&self) -> &str {
        "bash"
    }

    fn description(&self) -> &str {
        "Execute a shell command in the current workspace."
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "command": { "type": "string" },
                "timeout": { "type": "integer", "minimum": 1 },
                "description": { "type": "string" },
                "run_in_background": { "type": "boolean" },
                "dangerouslyDisableSandbox": { "type": "boolean" },
                "namespaceRestrictions": { "type": "boolean" },
                "isolateNetwork": { "type": "boolean" },
                "filesystemMode": { "type": "string", "enum": ["off", "workspace-only", "allow-list"] },
                "allowedMounts": { "type": "array", "items": { "type": "string" } }
            },
            "required": ["command"],
            "additionalProperties": false
        })
    }

    async fn execute(&self, input: Value) -> Result<ToolOutput> {
        let bash_input: BashCommandInput = serde_json::from_value(input)?;

        let timeout_ms = bash_input.timeout.unwrap_or(30_000);

        let (shell, flag) = if cfg!(target_os = "windows") {
            ("cmd", "/C")
        } else {
            ("sh", "-c")
        };

        if bash_input.run_in_background.unwrap_or(false) {
            let session_dir = std::env::var("MYAPP_SESSION_DIR")
                .map(std::path::PathBuf::from)
                .unwrap_or_else(|_| std::path::PathBuf::from(".sessions"));

            let task_id = crate::tools::task_manager::TaskManager::global()
                .spawn_task(bash_input.command, &session_dir)?;

            let log_path = session_dir.join("logs").join(format!("{}.log", task_id));

            let output = BashCommandOutput {
                stdout: String::new(),
                stderr: String::new(),
                raw_output_path: None,
                interrupted: false,
                is_image: None,
                background_task_id: Some(task_id),
                backgrounded_by_user: Some(false),
                assistant_auto_backgrounded: Some(false),
                dangerously_disable_sandbox: bash_input.dangerously_disable_sandbox,
                return_code_interpretation: None,
                no_output_expected: Some(true),
                structured_content: None,
                persisted_output_path: Some(log_path.to_string_lossy().to_string()),
                persisted_output_size: None,
                sandbox_status: None,
            };

            let serialized = serde_json::to_string_pretty(&output)?;
            return Ok(ToolOutput::success(serialized));
        }

        let result = tokio::time::timeout(
            Duration::from_millis(timeout_ms),
            Command::new(shell).arg(flag).arg(&bash_input.command).output(),
        )
        .await;

        match result {
            Ok(Ok(output)) => {
                let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
                let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
                let return_code_interpretation = output.status.code().and_then(|code| {
                    if code == 0 {
                        None
                    } else {
                        Some(format!("exit_code:{code}"))
                    }
                });
                let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());

                let command_output = BashCommandOutput {
                    stdout,
                    stderr,
                    raw_output_path: None,
                    interrupted: false,
                    is_image: None,
                    background_task_id: None,
                    backgrounded_by_user: None,
                    assistant_auto_backgrounded: None,
                    dangerously_disable_sandbox: bash_input.dangerously_disable_sandbox,
                    return_code_interpretation,
                    no_output_expected,
                    structured_content: None,
                    persisted_output_path: None,
                    persisted_output_size: None,
                    sandbox_status: None,
                };

                let serialized = serde_json::to_string_pretty(&command_output)?;
                if output.status.success() {
                    Ok(ToolOutput::success(serialized))
                } else {
                    Ok(ToolOutput::error(serialized))
                }
            }
            Ok(Err(e)) => {
                let command_output = BashCommandOutput {
                    stdout: String::new(),
                    stderr: format!("Failed to execute command: {e}"),
                    raw_output_path: None,
                    interrupted: false,
                    is_image: None,
                    background_task_id: None,
                    backgrounded_by_user: None,
                    assistant_auto_backgrounded: None,
                    dangerously_disable_sandbox: bash_input.dangerously_disable_sandbox,
                    return_code_interpretation: Some("execution_failed".to_string()),
                    no_output_expected: Some(false),
                    structured_content: None,
                    persisted_output_path: None,
                    persisted_output_size: None,
                    sandbox_status: None,
                };
                let serialized = serde_json::to_string_pretty(&command_output)?;
                Ok(ToolOutput::error(serialized))
            }
            Err(_) => {
                let command_output = BashCommandOutput {
                    stdout: String::new(),
                    stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
                    raw_output_path: None,
                    interrupted: true,
                    is_image: None,
                    background_task_id: None,
                    backgrounded_by_user: None,
                    assistant_auto_backgrounded: None,
                    dangerously_disable_sandbox: bash_input.dangerously_disable_sandbox,
                    return_code_interpretation: Some("timeout".to_string()),
                    no_output_expected: Some(false),
                    structured_content: None,
                    persisted_output_path: None,
                    persisted_output_size: None,
                    sandbox_status: None,
                };
                let serialized = serde_json::to_string_pretty(&command_output)?;
                Ok(ToolOutput::error(serialized))
            }
        }
    }
}