stynx-code-tools 3.12.1

Tool implementations for bash and file operations
Documentation
use std::sync::Arc;
use std::time::Duration;

use stynx_code_errors::{AppError, AppResult};
use stynx_code_types::{InterruptBehavior, PermissionLevel, Tool};
use serde_json::{Value, json};

use super::persistent_shell::ShellRegistry;

const MAX_OUTPUT_BYTES: usize = 100_000;
const DEFAULT_TIMEOUT_SECS: u64 = 120;

pub struct BashTool {
    shells: Arc<ShellRegistry>,
}

impl BashTool {
    pub fn new() -> Self {
        Self { shells: Arc::new(ShellRegistry::new()) }
    }

    pub fn shells(&self) -> Arc<ShellRegistry> {
        self.shells.clone()
    }
}

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

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

    fn description(&self) -> &str {
        "Run a bash command in a persistent shell session.\n\
\n\
Foreground (default): commands share a single long-lived bash process, so \
cwd, exported env vars, and shell aliases persist across calls. `cd subdir` \
in one call is visible to the next call.\n\
\n\
Long-running processes (dev servers, watchers, log tails): pass \
`background: true`. You get back a handle like `bg1`. Read its output with \
`{\"status\": \"bg1\"}` (returns only new output since last read), or \
`{\"status\": \"bg1\", \"full\": true}` for everything. Stop it with \
`{\"kill\": \"bg1\"}`. List all background processes with `{\"list\": true}`.\n\
\n\
Use `timeout` (seconds) to cap a foreground command — default 120s. \
If a foreground command times out, the shell may be in an unknown state; \
prefer `background: true` for anything that legitimately runs longer."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "Bash command to run. Required unless using status/kill/list."
                },
                "background": {
                    "type": "boolean",
                    "description": "If true, spawn the command detached. Returns a handle like 'bg1' instead of waiting for completion."
                },
                "status": {
                    "type": "string",
                    "description": "Handle of a background process to read new output from."
                },
                "full": {
                    "type": "boolean",
                    "description": "When reading status, return all accumulated output instead of only new output since last read."
                },
                "kill": {
                    "type": "string",
                    "description": "Handle of a background process to terminate."
                },
                "list": {
                    "type": "boolean",
                    "description": "If true, list all background processes."
                },
                "timeout": {
                    "type": "integer",
                    "description": "Foreground command timeout in seconds (default 120)."
                }
            }
        })
    }

    fn permission_level(&self) -> PermissionLevel { PermissionLevel::Dangerous }

    fn interrupt_behavior(&self) -> InterruptBehavior { InterruptBehavior::Cancel }

    async fn execute(&self, input: Value) -> AppResult<String> {
        if input.get("list").and_then(|v| v.as_bool()).unwrap_or(false) {
            return Ok(self.shells.list_background().await);
        }

        if let Some(handle) = input.get("kill").and_then(|v| v.as_str()) {
            return self.shells.kill_background(handle).await;
        }

        if let Some(handle) = input.get("status").and_then(|v| v.as_str()) {
            let full = input.get("full").and_then(|v| v.as_bool()).unwrap_or(false);
            return self.shells.read_background(handle, full).await;
        }

        let command = input
            .get("command")
            .and_then(|v| v.as_str())
            .ok_or_else(|| AppError::Tool("missing 'command' field".into()))?;

        let background = input.get("background").and_then(|v| v.as_bool()).unwrap_or(false);
        if background {
            tracing::info!(command, "starting background process");
            let handle = self.shells.run_background(command).await?;
            return Ok(format!(
                "started background process '{handle}'.\n\
read with {{\"status\":\"{handle}\"}}, stop with {{\"kill\":\"{handle}\"}}."
            ));
        }

        let timeout_secs = input
            .get("timeout")
            .and_then(|v| v.as_u64())
            .unwrap_or(DEFAULT_TIMEOUT_SECS);
        tracing::info!(command, timeout_secs, "executing bash (persistent)");

        let mut result = self
            .shells
            .run_sync(command, Some(Duration::from_secs(timeout_secs)))
            .await?;

        if result.len() > MAX_OUTPUT_BYTES {
            result.truncate(MAX_OUTPUT_BYTES);
            result.push_str("\n... (truncated)");
        }
        Ok(result)
    }
}