Files
owlen/crates/tools/bash/src/lib.rs
vikingowl 10c8e2baae feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features
Multi-LLM Provider Support:
- Add llm-core crate with LlmProvider trait abstraction
- Implement Anthropic Claude API client with streaming
- Implement OpenAI API client with streaming
- Add token counting with SimpleTokenCounter and ClaudeTokenCounter
- Add retry logic with exponential backoff and jitter

Borderless TUI Redesign:
- Rewrite theme system with terminal capability detection (Full/Unicode256/Basic)
- Add provider tabs component with keybind switching [1]/[2]/[3]
- Implement vim-modal input (Normal/Insert/Visual/Command modes)
- Redesign chat panel with timestamps and streaming indicators
- Add multi-provider status bar with cost tracking
- Add Nerd Font icons with graceful ASCII fallbacks
- Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark)

Advanced Agent Features:
- Add system prompt builder with configurable components
- Enhance subagent orchestration with parallel execution
- Add git integration module for safe command detection
- Add streaming tool results via channels
- Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell
- Add WebSearch with provider abstraction

Plugin System Enhancement:
- Add full agent definition parsing from YAML frontmatter
- Add skill system with progressive disclosure
- Wire plugin hooks into HookManager

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 17:24:14 +01:00

282 lines
9.2 KiB
Rust

use color_eyre::eyre::{Result, eyre};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use parking_lot::RwLock;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
const MAX_OUTPUT_LINES: usize = 2000;
const DEFAULT_TIMEOUT_MS: u64 = 120000; // 2 minutes
const COMMAND_DELIMITER: &str = "___OWLEN_CMD_END___";
#[derive(Debug, Clone)]
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub success: bool,
}
pub struct BashSession {
child: Mutex<Child>,
last_output: Option<String>,
}
impl BashSession {
/// Create a new persistent bash session
pub async fn new() -> Result<Self> {
let child = Command::new("bash")
.arg("--norc")
.arg("--noprofile")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
// Verify the process started
if child.stdin.is_none() || child.stdout.is_none() || child.stderr.is_none() {
return Err(eyre!("Failed to capture bash process stdio"));
}
Ok(Self {
child: Mutex::new(child),
last_output: None,
})
}
/// Execute a command in the persistent bash session
///
/// # Arguments
/// * `command` - The bash command to execute
/// * `timeout_ms` - Optional timeout in milliseconds (default: 2 minutes)
pub async fn execute(&mut self, command: &str, timeout_ms: Option<u64>) -> Result<CommandOutput> {
let timeout_duration = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
let result = timeout(timeout_duration, self.execute_internal(command)).await;
match result {
Ok(output) => {
// Store the output for potential retrieval via BashOutput tool
let combined = format!("{}{}", output.as_ref().map(|o| o.stdout.as_str()).unwrap_or(""),
output.as_ref().map(|o| o.stderr.as_str()).unwrap_or(""));
self.last_output = Some(combined);
output
},
Err(_) => Err(eyre!("Command timed out after {}ms", timeout_duration.as_millis())),
}
}
async fn execute_internal(&mut self, command: &str) -> Result<CommandOutput> {
let mut child = self.child.lock().await;
// Take ownership of stdio handles
let mut stdin = child.stdin.take().ok_or_else(|| eyre!("No stdin"))?;
let stdout = child.stdout.take().ok_or_else(|| eyre!("No stdout"))?;
let stderr = child.stderr.take().ok_or_else(|| eyre!("No stderr"))?;
// Write command with delimiter and exit code capture
let full_command = format!(
"{}\necho $? > /tmp/owlen_exit_code_$$.tmp\necho '{}'\n",
command, COMMAND_DELIMITER
);
stdin.write_all(full_command.as_bytes()).await?;
stdin.flush().await?;
// Read stdout until delimiter
let mut stdout_reader = BufReader::new(stdout);
let mut stdout_lines = Vec::new();
let mut line = String::new();
loop {
line.clear();
let n = stdout_reader.read_line(&mut line).await?;
if n == 0 {
return Err(eyre!("Bash process terminated unexpectedly"));
}
if line.trim() == COMMAND_DELIMITER {
break;
}
stdout_lines.push(line.clone());
// Truncate if too many lines
if stdout_lines.len() > MAX_OUTPUT_LINES {
stdout_lines.push("<<<...output truncated...>>>\n".to_string());
break;
}
}
// Read stderr (non-blocking, best effort)
let mut stderr_reader = BufReader::new(stderr);
let mut stderr_lines = Vec::new();
let mut stderr_line = String::new();
// Try to read stderr without blocking indefinitely
while let Ok(result) = timeout(Duration::from_millis(100), stderr_reader.read_line(&mut stderr_line)).await {
match result {
Ok(n) if n > 0 => {
stderr_lines.push(stderr_line.clone());
stderr_line.clear();
if stderr_lines.len() > MAX_OUTPUT_LINES {
stderr_lines.push("<<<...stderr truncated...>>>\n".to_string());
break;
}
}
_ => break,
}
}
// Read exit code
let exit_code_cmd = "cat /tmp/owlen_exit_code_$$.tmp 2>/dev/null; rm -f /tmp/owlen_exit_code_$$.tmp\n";
stdin.write_all(exit_code_cmd.as_bytes()).await?;
stdin.flush().await?;
let mut exit_line = String::new();
stdout_reader.read_line(&mut exit_line).await?;
let exit_code: i32 = exit_line.trim().parse().unwrap_or(0);
// Restore stdio handles
child.stdin = Some(stdin);
child.stdout = Some(stdout_reader.into_inner());
child.stderr = Some(stderr_reader.into_inner());
Ok(CommandOutput {
stdout: stdout_lines.join(""),
stderr: stderr_lines.join(""),
exit_code,
success: exit_code == 0,
})
}
/// Close the bash session
pub async fn close(self) -> Result<()> {
let mut child = self.child.into_inner();
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(b"exit\n").await;
let _ = stdin.flush().await;
}
let _ = child.wait().await?;
Ok(())
}
}
/// Manages background bash shells by ID
#[derive(Clone, Default)]
pub struct ShellManager {
shells: Arc<RwLock<HashMap<String, BashSession>>>,
}
impl ShellManager {
pub fn new() -> Self {
Self::default()
}
/// Start a new background shell, returns shell ID
pub async fn start_shell(&self) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
let session = BashSession::new().await?;
self.shells.write().insert(id.clone(), session);
Ok(id)
}
/// Execute command in background shell
pub async fn execute(&self, shell_id: &str, command: &str, timeout: Option<Duration>) -> Result<CommandOutput> {
// We need to handle this carefully to avoid holding the lock across await
// First check if the shell exists and clone what we need
let exists = self.shells.read().contains_key(shell_id);
if !exists {
return Err(eyre!("Shell not found: {}", shell_id));
}
// For now, we need to use a more complex approach since BashSession contains async operations
// We'll execute and then update in a separate critical section
let timeout_ms = timeout.map(|d| d.as_millis() as u64);
// Take temporary ownership for execution
let mut session = {
let mut shells = self.shells.write();
shells.remove(shell_id)
.ok_or_else(|| eyre!("Shell not found: {}", shell_id))?
};
// Execute without holding the lock
let result = session.execute(command, timeout_ms).await;
// Put the session back
self.shells.write().insert(shell_id.to_string(), session);
result
}
/// Get output from a shell (BashOutput tool)
pub fn get_output(&self, shell_id: &str) -> Result<Option<String>> {
let shells = self.shells.read();
let session = shells.get(shell_id)
.ok_or_else(|| eyre!("Shell not found: {}", shell_id))?;
// Return any buffered output
Ok(session.last_output.clone())
}
/// Kill a shell (KillShell tool)
pub fn kill_shell(&self, shell_id: &str) -> Result<()> {
let mut shells = self.shells.write();
if shells.remove(shell_id).is_some() {
Ok(())
} else {
Err(eyre!("Shell not found: {}", shell_id))
}
}
/// List active shells
pub fn list_shells(&self) -> Vec<String> {
self.shells.read().keys().cloned().collect()
}
}
/// Start a background bash command, returns shell ID
pub async fn run_background(manager: &ShellManager, command: &str) -> Result<String> {
let shell_id = manager.start_shell().await?;
// Execute in background (non-blocking)
tokio::spawn({
let manager = manager.clone();
let command = command.to_string();
let shell_id = shell_id.clone();
async move {
let _ = manager.execute(&shell_id, &command, None).await;
}
});
Ok(shell_id)
}
/// Get output from background shell (BashOutput tool)
pub fn bash_output(manager: &ShellManager, shell_id: &str) -> Result<String> {
manager.get_output(shell_id)?
.ok_or_else(|| eyre!("No output available"))
}
/// Kill a background shell (KillShell tool)
pub fn kill_shell(manager: &ShellManager, shell_id: &str) -> Result<String> {
manager.kill_shell(shell_id)?;
Ok(format!("Shell {} terminated", shell_id))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn can_create_session() {
let session = BashSession::new().await;
assert!(session.is_ok());
}
}