From 02e4d7fb37fc3dfad016c4167c254e89eb03a265 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Mon, 23 Mar 2026 11:57:24 +0000 Subject: [PATCH] feat(code): CLI client with gRPC connection + local tools phase 3 client core: - sunbeam code subcommand with project discovery, config loading - gRPC client connects to Sol, starts bidirectional session - 7 client-side tool executors: file_read, file_write, search_replace, grep, bash, list_directory - project context: .sunbeam/prompt.md, .sunbeam/config.toml, git info - tool permission config (always/ask/never per tool) - simple stdin loop (ratatui TUI in phase 4) - aligned sunbeam-proto to tonic 0.14 --- sunbeam-proto/Cargo.toml | 8 +- sunbeam-proto/build.rs | 2 +- sunbeam/Cargo.toml | 9 ++ sunbeam/src/cli.rs | 8 + sunbeam/src/code/client.rs | 185 +++++++++++++++++++++++ sunbeam/src/code/config.rs | 117 +++++++++++++++ sunbeam/src/code/mod.rs | 97 ++++++++++++ sunbeam/src/code/project.rs | 131 +++++++++++++++++ sunbeam/src/code/tools.rs | 284 ++++++++++++++++++++++++++++++++++++ sunbeam/src/main.rs | 1 + 10 files changed, 838 insertions(+), 4 deletions(-) create mode 100644 sunbeam/src/code/client.rs create mode 100644 sunbeam/src/code/config.rs create mode 100644 sunbeam/src/code/mod.rs create mode 100644 sunbeam/src/code/project.rs create mode 100644 sunbeam/src/code/tools.rs diff --git a/sunbeam-proto/Cargo.toml b/sunbeam-proto/Cargo.toml index 68f03d7..ee7e5e9 100644 --- a/sunbeam-proto/Cargo.toml +++ b/sunbeam-proto/Cargo.toml @@ -5,8 +5,10 @@ edition = "2024" description = "Shared protobuf definitions for Sunbeam gRPC services" [dependencies] -tonic = "0.13" -prost = "0.13" +tonic = "0.14" +tonic-prost = "0.14" +prost = "0.14" [build-dependencies] -tonic-build = "0.13" +tonic-build = "0.14" +tonic-prost-build = "0.14" diff --git a/sunbeam-proto/build.rs b/sunbeam-proto/build.rs index 457a429..8c3f998 100644 --- a/sunbeam-proto/build.rs +++ b/sunbeam-proto/build.rs @@ -1,4 +1,4 @@ fn main() -> Result<(), Box> { - tonic_build::compile_protos("proto/code.proto")?; + tonic_prost_build::compile_protos("proto/code.proto")?; Ok(()) } diff --git a/sunbeam/Cargo.toml b/sunbeam/Cargo.toml index abbf569..dd75082 100644 --- a/sunbeam/Cargo.toml +++ b/sunbeam/Cargo.toml @@ -10,9 +10,18 @@ path = "src/main.rs" [dependencies] sunbeam-sdk = { path = "../sunbeam-sdk", features = ["all", "cli"] } +sunbeam-proto = { path = "../sunbeam-proto" } tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" clap = { version = "4", features = ["derive"] } chrono = "0.4" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } rustls = { version = "0.23", features = ["ring"] } +tonic = "0.14" +ratatui = "0.29" +crossterm = "0.28" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +anyhow = "1" diff --git a/sunbeam/src/cli.rs b/sunbeam/src/cli.rs index 069cc86..94e778a 100644 --- a/sunbeam/src/cli.rs +++ b/sunbeam/src/cli.rs @@ -139,6 +139,12 @@ pub enum Verb { action: Option, }, + /// Terminal coding agent powered by Sol. + Code { + #[command(subcommand)] + action: Option, + }, + /// Self-update from latest mainline commit. Update, @@ -1053,6 +1059,8 @@ pub async fn dispatch() -> Result<()> { } }, + Some(Verb::Code { action }) => crate::code::cmd_code(action).await, + Some(Verb::Update) => sunbeam_sdk::update::cmd_update().await, Some(Verb::Version) => { diff --git a/sunbeam/src/code/client.rs b/sunbeam/src/code/client.rs new file mode 100644 index 0000000..804a41e --- /dev/null +++ b/sunbeam/src/code/client.rs @@ -0,0 +1,185 @@ +use sunbeam_proto::sunbeam_code_v1::code_agent_client::CodeAgentClient; +use sunbeam_proto::sunbeam_code_v1::*; + +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::Request; +use tracing::{debug, error, info, warn}; + +use super::config::LoadedConfig; +use super::project::ProjectContext; + +/// An active coding session connected to Sol via gRPC. +pub struct CodeSession { + pub session_id: String, + pub room_id: String, + pub model: String, + pub project_path: String, + tx: mpsc::Sender, + rx: tonic::Streaming, +} + +/// Connect to Sol's gRPC server and start a coding session. +pub async fn connect( + endpoint: &str, + project: &ProjectContext, + config: &LoadedConfig, + model: &str, +) -> anyhow::Result { + let mut client = CodeAgentClient::connect(endpoint.to_string()) + .await + .map_err(|e| anyhow::anyhow!("Failed to connect to Sol at {endpoint}: {e}"))?; + + info!(endpoint, "Connected to Sol gRPC server"); + + // Create the bidirectional stream + let (tx, client_rx) = mpsc::channel::(32); + let client_stream = ReceiverStream::new(client_rx); + + // TODO: add JWT auth token to the request metadata + let response = client.session(client_stream).await?; + let mut rx = response.into_inner(); + + // Send StartSession + tx.send(ClientMessage { + payload: Some(client_message::Payload::Start(StartSession { + project_path: project.path.clone(), + prompt_md: project.prompt_md.clone(), + config_toml: project.config_toml.clone(), + git_branch: project.git_branch.clone().unwrap_or_default(), + git_status: project.git_status.clone().unwrap_or_default(), + file_tree: project.file_tree.clone(), + model: model.into(), + client_tools: vec![], // TODO: send client tool schemas + })), + }) + .await?; + + // Wait for SessionReady + let ready = loop { + match rx.message().await? { + Some(ServerMessage { + payload: Some(server_message::Payload::Ready(r)), + }) => break r, + Some(ServerMessage { + payload: Some(server_message::Payload::Error(e)), + }) => anyhow::bail!("Session start failed: {}", e.message), + Some(_) => continue, + None => anyhow::bail!("Stream closed before SessionReady"), + } + }; + + Ok(CodeSession { + session_id: ready.session_id, + room_id: ready.room_id, + model: ready.model, + project_path: project.path.clone(), + tx, + rx, + }) +} + +impl CodeSession { + /// Send a chat message and collect the response. + /// Handles tool calls by executing them locally and sending results back. + pub async fn chat(&mut self, text: &str) -> anyhow::Result { + self.tx + .send(ClientMessage { + payload: Some(client_message::Payload::Input(UserInput { + text: text.into(), + })), + }) + .await?; + + // Read server messages until we get TextDone + loop { + match self.rx.message().await? { + Some(ServerMessage { + payload: Some(server_message::Payload::Delta(d)), + }) => { + // Streaming text โ€” print incrementally + print!("{}", d.text); + } + Some(ServerMessage { + payload: Some(server_message::Payload::Done(d)), + }) => { + return Ok(d.full_text); + } + Some(ServerMessage { + payload: Some(server_message::Payload::ToolCall(tc)), + }) => { + if tc.is_local { + // Execute locally + if tc.needs_approval { + eprint!(" [{}] approve? (y/n) ", tc.name); + // Simple stdin approval for now + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().starts_with('y') { + self.tx + .send(ClientMessage { + payload: Some(client_message::Payload::ToolResult( + ToolResult { + call_id: tc.call_id.clone(), + result: "Denied by user.".into(), + is_error: true, + }, + )), + }) + .await?; + continue; + } + } + + eprintln!(" ๐Ÿ”ง {}", tc.name); + let result = + super::tools::execute(&tc.name, &tc.args_json, &self.project_path); + + self.tx + .send(ClientMessage { + payload: Some(client_message::Payload::ToolResult(ToolResult { + call_id: tc.call_id, + result, + is_error: false, + })), + }) + .await?; + } else { + // Server-side tool โ€” Sol handles it, we just see the status + eprintln!(" ๐Ÿ”ง {} (server)", tc.name); + } + } + Some(ServerMessage { + payload: Some(server_message::Payload::Status(s)), + }) => { + eprintln!(" [{}]", s.message); + } + Some(ServerMessage { + payload: Some(server_message::Payload::Error(e)), + }) => { + if e.fatal { + anyhow::bail!("Fatal error: {}", e.message); + } + eprintln!(" error: {}", e.message); + } + Some(ServerMessage { + payload: Some(server_message::Payload::End(_)), + }) => { + return Ok("Session ended by server.".into()); + } + Some(_) => continue, + None => anyhow::bail!("Stream closed unexpectedly"), + } + } + } + + /// End the session. + pub async fn end(&self) -> anyhow::Result<()> { + self.tx + .send(ClientMessage { + payload: Some(client_message::Payload::End(EndSession {})), + }) + .await?; + Ok(()) + } +} diff --git a/sunbeam/src/code/config.rs b/sunbeam/src/code/config.rs new file mode 100644 index 0000000..8ebea15 --- /dev/null +++ b/sunbeam/src/code/config.rs @@ -0,0 +1,117 @@ +use serde::Deserialize; + +/// Project-level configuration from .sunbeam/config.toml. +#[derive(Debug, Default, Deserialize)] +pub struct ProjectConfig { + #[serde(default)] + pub model: Option, + #[serde(default)] + pub tools: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ModelConfig { + pub name: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ToolPermissions { + #[serde(default)] + pub file_read: Option, + #[serde(default)] + pub file_write: Option, + #[serde(default)] + pub search_replace: Option, + #[serde(default)] + pub grep: Option, + #[serde(default)] + pub bash: Option, + #[serde(default)] + pub list_directory: Option, +} + +/// Convenience wrapper with flattened fields. +pub struct LoadedConfig { + pub model_name: Option, + pub file_read_perm: String, + pub file_write_perm: String, + pub search_replace_perm: String, + pub grep_perm: String, + pub bash_perm: String, + pub list_directory_perm: String, +} + +impl Default for LoadedConfig { + fn default() -> Self { + Self { + model_name: None, + file_read_perm: "always".into(), + file_write_perm: "ask".into(), + search_replace_perm: "ask".into(), + grep_perm: "always".into(), + bash_perm: "ask".into(), + list_directory_perm: "always".into(), + } + } +} + +/// Load project config from .sunbeam/config.toml. +pub fn load_project_config(project_path: &str) -> LoadedConfig { + let config_path = std::path::Path::new(project_path) + .join(".sunbeam") + .join("config.toml"); + + let raw = match std::fs::read_to_string(&config_path) { + Ok(s) => s, + Err(_) => return LoadedConfig::default(), + }; + + let parsed: ProjectConfig = match toml::from_str(&raw) { + Ok(c) => c, + Err(e) => { + eprintln!("warning: failed to parse .sunbeam/config.toml: {e}"); + return LoadedConfig::default(); + } + }; + + let tools = parsed.tools.unwrap_or_default(); + + LoadedConfig { + model_name: parsed.model.and_then(|m| m.name), + file_read_perm: tools.file_read.unwrap_or_else(|| "always".into()), + file_write_perm: tools.file_write.unwrap_or_else(|| "ask".into()), + search_replace_perm: tools.search_replace.unwrap_or_else(|| "ask".into()), + grep_perm: tools.grep.unwrap_or_else(|| "always".into()), + bash_perm: tools.bash.unwrap_or_else(|| "ask".into()), + list_directory_perm: tools.list_directory.unwrap_or_else(|| "always".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let cfg = LoadedConfig::default(); + assert_eq!(cfg.file_read_perm, "always"); + assert_eq!(cfg.file_write_perm, "ask"); + assert_eq!(cfg.bash_perm, "ask"); + assert!(cfg.model_name.is_none()); + } + + #[test] + fn test_parse_config() { + let toml = r#" +[model] +name = "devstral-2" + +[tools] +file_read = "always" +bash = "never" +"#; + let parsed: ProjectConfig = toml::from_str(toml).unwrap(); + assert_eq!(parsed.model.unwrap().name.unwrap(), "devstral-2"); + assert_eq!(parsed.tools.unwrap().bash.unwrap(), "never"); + } +} diff --git a/sunbeam/src/code/mod.rs b/sunbeam/src/code/mod.rs new file mode 100644 index 0000000..2b8b843 --- /dev/null +++ b/sunbeam/src/code/mod.rs @@ -0,0 +1,97 @@ +pub mod client; +pub mod config; +pub mod project; +pub mod tools; + +use clap::Subcommand; +use tracing::info; + +#[derive(Subcommand, Debug)] +pub enum CodeCommand { + /// Start a coding session (default โ€” can omit subcommand) + Start { + /// Model override (e.g., devstral-2) + #[arg(long)] + model: Option, + /// Sol gRPC endpoint (default: from sunbeam config) + #[arg(long)] + endpoint: Option, + }, +} + +pub async fn cmd_code(cmd: Option) -> sunbeam_sdk::error::Result<()> { + cmd_code_inner(cmd).await.map_err(|e| sunbeam_sdk::error::SunbeamError::Other(e.to_string())) +} + +async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { + let cmd = cmd.unwrap_or(CodeCommand::Start { + model: None, + endpoint: None, + }); + + match cmd { + CodeCommand::Start { model, endpoint } => { + let endpoint = endpoint.unwrap_or_else(|| "http://127.0.0.1:50051".into()); + + // Discover project context + let project = project::discover_project(".")?; + info!( + project = project.name.as_str(), + path = project.path.as_str(), + branch = project.git_branch.as_deref().unwrap_or("?"), + "Discovered project" + ); + + // Load project config + let cfg = config::load_project_config(&project.path); + + let model = model + .or(cfg.model_name.clone()) + .unwrap_or_else(|| "devstral-small-2506".into()); + + // Connect to Sol + let mut session = client::connect(&endpoint, &project, &cfg, &model).await?; + + info!( + session_id = session.session_id.as_str(), + room_id = session.room_id.as_str(), + model = session.model.as_str(), + "Connected to Sol" + ); + + // For now, simple stdin loop (ratatui TUI in Phase 4) + println!("sunbeam code ยท {} ยท {}", project.name, model); + println!("connected to Sol (session: {})", &session.session_id[..8]); + println!("type a message, /quit to exit\n"); + + let stdin = tokio::io::stdin(); + let reader = tokio::io::BufReader::new(stdin); + use tokio::io::AsyncBufReadExt; + let mut lines = reader.lines(); + + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + if line == "/quit" { + session.end().await?; + println!("session ended."); + break; + } + + print!("> "); + match session.chat(&line).await { + Ok(response) => { + println!("\n{}\n", response); + } + Err(e) => { + eprintln!("error: {e}"); + } + } + } + + Ok(()) + } + } +} diff --git a/sunbeam/src/code/project.rs b/sunbeam/src/code/project.rs new file mode 100644 index 0000000..068fc1f --- /dev/null +++ b/sunbeam/src/code/project.rs @@ -0,0 +1,131 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Discovered project context sent to Sol on session start. +pub struct ProjectContext { + pub name: String, + pub path: String, + pub prompt_md: String, + pub config_toml: String, + pub git_branch: Option, + pub git_status: Option, + pub file_tree: Vec, +} + +/// Discover project context from the working directory. +pub fn discover_project(dir: &str) -> anyhow::Result { + let path = std::fs::canonicalize(dir)?; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let prompt_md = read_optional(&path.join(".sunbeam").join("prompt.md")); + let config_toml = read_optional(&path.join(".sunbeam").join("config.toml")); + + let git_branch = run_git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]); + let git_status = run_git(&path, &["status", "--short"]); + + let file_tree = list_tree(&path, 2); + + Ok(ProjectContext { + name, + path: path.to_string_lossy().into(), + prompt_md, + config_toml, + git_branch, + git_status, + file_tree, + }) +} + +fn read_optional(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_default() +} + +fn run_git(dir: &Path, args: &[&str]) -> Option { + Command::new("git") + .args(args) + .current_dir(dir) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +fn list_tree(dir: &Path, max_depth: usize) -> Vec { + let mut entries = Vec::new(); + list_tree_inner(dir, dir, 0, max_depth, &mut entries); + entries +} + +fn list_tree_inner( + base: &Path, + dir: &Path, + depth: usize, + max_depth: usize, + entries: &mut Vec, +) { + if depth > max_depth { + return; + } + + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + + let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect(); + items.sort_by_key(|e| e.file_name()); + + for entry in items { + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip hidden dirs, target, node_modules, vendor + if name.starts_with('.') || name == "target" || name == "node_modules" || name == "vendor" + { + continue; + } + + let relative = entry + .path() + .strip_prefix(base) + .unwrap_or(&entry.path()) + .to_string_lossy() + .to_string(); + + entries.push(relative); + + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + list_tree_inner(base, &entry.path(), depth + 1, max_depth, entries); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discover_current_dir() { + // Should work in any directory + let ctx = discover_project(".").unwrap(); + assert!(!ctx.name.is_empty()); + assert!(!ctx.path.is_empty()); + } + + #[test] + fn test_list_tree_excludes_hidden() { + let dir = std::env::temp_dir().join("sunbeam-test-tree"); + let _ = std::fs::create_dir_all(dir.join(".hidden")); + let _ = std::fs::create_dir_all(dir.join("visible")); + let _ = std::fs::write(dir.join("file.txt"), "test"); + + let tree = list_tree(&dir, 1); + assert!(tree.iter().any(|e| e == "visible")); + assert!(tree.iter().any(|e| e == "file.txt")); + assert!(!tree.iter().any(|e| e.contains(".hidden"))); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/sunbeam/src/code/tools.rs b/sunbeam/src/code/tools.rs new file mode 100644 index 0000000..317b7d5 --- /dev/null +++ b/sunbeam/src/code/tools.rs @@ -0,0 +1,284 @@ +use std::path::Path; +use std::process::Command; + +use serde_json::Value; +use tracing::info; + +/// Execute a client-side tool and return the result as a string. +pub fn execute(name: &str, args_json: &str, project_root: &str) -> String { + let args: Value = serde_json::from_str(args_json).unwrap_or_default(); + + match name { + "file_read" => file_read(&args, project_root), + "file_write" => file_write(&args, project_root), + "search_replace" => search_replace(&args, project_root), + "grep" => grep(&args, project_root), + "bash" => bash(&args, project_root), + "list_directory" => list_directory(&args, project_root), + _ => format!("Unknown client tool: {name}"), + } +} + +fn resolve_path(path: &str, project_root: &str) -> String { + let p = Path::new(path); + if p.is_absolute() { + path.to_string() + } else { + Path::new(project_root) + .join(path) + .to_string_lossy() + .into() + } +} + +fn file_read(args: &Value, root: &str) -> String { + let path = args["path"].as_str().unwrap_or(""); + let resolved = resolve_path(path, root); + + let content = match std::fs::read_to_string(&resolved) { + Ok(c) => c, + Err(e) => return format!("Error reading {path}: {e}"), + }; + + let start = args["start_line"].as_u64().map(|n| n as usize); + let end = args["end_line"].as_u64().map(|n| n as usize); + + match (start, end) { + (Some(s), Some(e)) => { + let lines: Vec<&str> = content.lines().collect(); + let s = s.saturating_sub(1).min(lines.len()); + let e = e.min(lines.len()); + lines[s..e].join("\n") + } + (Some(s), None) => { + let lines: Vec<&str> = content.lines().collect(); + let s = s.saturating_sub(1).min(lines.len()); + lines[s..].join("\n") + } + _ => content, + } +} + +fn file_write(args: &Value, root: &str) -> String { + let path = args["path"].as_str().unwrap_or(""); + let content = args["content"].as_str().unwrap_or(""); + let resolved = resolve_path(path, root); + + // Ensure parent directory exists + if let Some(parent) = Path::new(&resolved).parent() { + let _ = std::fs::create_dir_all(parent); + } + + match std::fs::write(&resolved, content) { + Ok(_) => format!("Written {} bytes to {path}", content.len()), + Err(e) => format!("Error writing {path}: {e}"), + } +} + +fn search_replace(args: &Value, root: &str) -> String { + let path = args["path"].as_str().unwrap_or(""); + let diff = args["diff"].as_str().unwrap_or(""); + let resolved = resolve_path(path, root); + + let content = match std::fs::read_to_string(&resolved) { + Ok(c) => c, + Err(e) => return format!("Error reading {path}: {e}"), + }; + + // Parse SEARCH/REPLACE blocks + let mut result = content.clone(); + let mut replacements = 0; + + for block in diff.split("<<<< SEARCH\n").skip(1) { + let parts: Vec<&str> = block.splitn(2, "=====\n").collect(); + if parts.len() != 2 { + continue; + } + let search = parts[0].trim_end_matches('\n'); + let rest: Vec<&str> = parts[1].splitn(2, ">>>>> REPLACE").collect(); + if rest.is_empty() { + continue; + } + let replace = rest[0].trim_end_matches('\n'); + + if result.contains(search) { + result = result.replacen(search, replace, 1); + replacements += 1; + } + } + + if replacements > 0 { + match std::fs::write(&resolved, &result) { + Ok(_) => format!("{replacements} replacement(s) applied to {path}"), + Err(e) => format!("Error writing {path}: {e}"), + } + } else { + format!("No matches found in {path}") + } +} + +fn grep(args: &Value, root: &str) -> String { + let pattern = args["pattern"].as_str().unwrap_or(""); + let path = args["path"].as_str().unwrap_or("."); + let resolved = resolve_path(path, root); + + // Try rg first, fall back to grep + let output = Command::new("rg") + .args(["--no-heading", "--line-number", pattern, &resolved]) + .output() + .or_else(|_| { + Command::new("grep") + .args(["-rn", pattern, &resolved]) + .output() + }); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + if stdout.is_empty() { + format!("No matches for '{pattern}' in {path}") + } else { + // Truncate if too long + if stdout.len() > 8192 { + format!("{}...\n(truncated)", &stdout[..8192]) + } else { + stdout.into() + } + } + } + Err(e) => format!("Error running grep: {e}"), + } +} + +fn bash(args: &Value, root: &str) -> String { + let command = args["command"].as_str().unwrap_or(""); + + info!(command, "Executing bash command"); + + let output = Command::new("sh") + .args(["-c", command]) + .current_dir(root) + .output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + let stderr = String::from_utf8_lossy(&o.stderr); + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(&stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("stderr: "); + result.push_str(&stderr); + } + if !o.status.success() { + result.push_str(&format!("\nexit code: {}", o.status.code().unwrap_or(-1))); + } + if result.len() > 16384 { + format!("{}...\n(truncated)", &result[..16384]) + } else { + result + } + } + Err(e) => format!("Error: {e}"), + } +} + +fn list_directory(args: &Value, root: &str) -> String { + let path = args["path"].as_str().unwrap_or("."); + let depth = args["depth"].as_u64().unwrap_or(1) as usize; + let resolved = resolve_path(path, root); + + let mut entries = Vec::new(); + list_dir_inner(Path::new(&resolved), Path::new(&resolved), 0, depth, &mut entries); + + if entries.is_empty() { + format!("Empty directory: {path}") + } else { + entries.join("\n") + } +} + +fn list_dir_inner( + base: &Path, + dir: &Path, + depth: usize, + max_depth: usize, + entries: &mut Vec, +) { + if depth > max_depth { + return; + } + + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + + let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect(); + items.sort_by_key(|e| e.file_name()); + + for entry in items { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || name == "target" || name == "node_modules" || name == "vendor" { + continue; + } + + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + let relative = entry + .path() + .strip_prefix(base) + .unwrap_or(&entry.path()) + .to_string_lossy() + .to_string(); + + let prefix = " ".repeat(depth); + let marker = if is_dir { "/" } else { "" }; + entries.push(format!("{prefix}{relative}{marker}")); + + if is_dir { + list_dir_inner(base, &entry.path(), depth + 1, max_depth, entries); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_path_relative() { + let resolved = resolve_path("src/main.rs", "/project"); + assert_eq!(resolved, "/project/src/main.rs"); + } + + #[test] + fn test_resolve_path_absolute() { + let resolved = resolve_path("/etc/hosts", "/project"); + assert_eq!(resolved, "/etc/hosts"); + } + + #[test] + fn test_file_read_nonexistent() { + let args = serde_json::json!({"path": "/nonexistent/file.txt"}); + let result = file_read(&args, "/tmp"); + assert!(result.contains("Error")); + } + + #[test] + fn test_bash_echo() { + let args = serde_json::json!({"command": "echo hello"}); + let result = bash(&args, "/tmp"); + assert_eq!(result.trim(), "hello"); + } + + #[test] + fn test_bash_exit_code() { + let args = serde_json::json!({"command": "false"}); + let result = bash(&args, "/tmp"); + assert!(result.contains("exit code")); + } +} diff --git a/sunbeam/src/main.rs b/sunbeam/src/main.rs index 826a5be..cbac297 100644 --- a/sunbeam/src/main.rs +++ b/sunbeam/src/main.rs @@ -1,4 +1,5 @@ mod cli; +mod code; #[tokio::main] async fn main() {