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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_build::compile_protos("proto/code.proto")?;
|
||||
tonic_prost_build::compile_protos("proto/code.proto")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -139,6 +139,12 @@ pub enum Verb {
|
||||
action: Option<PmAction>,
|
||||
},
|
||||
|
||||
/// Terminal coding agent powered by Sol.
|
||||
Code {
|
||||
#[command(subcommand)]
|
||||
action: Option<crate::code::CodeCommand>,
|
||||
},
|
||||
|
||||
/// 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) => {
|
||||
|
||||
185
sunbeam/src/code/client.rs
Normal file
185
sunbeam/src/code/client.rs
Normal file
@@ -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<ClientMessage>,
|
||||
rx: tonic::Streaming<ServerMessage>,
|
||||
}
|
||||
|
||||
/// 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<CodeSession> {
|
||||
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::<ClientMessage>(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<String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
117
sunbeam/src/code/config.rs
Normal file
117
sunbeam/src/code/config.rs
Normal file
@@ -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<ModelConfig>,
|
||||
#[serde(default)]
|
||||
pub tools: Option<ToolPermissions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelConfig {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ToolPermissions {
|
||||
#[serde(default)]
|
||||
pub file_read: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file_write: Option<String>,
|
||||
#[serde(default)]
|
||||
pub search_replace: Option<String>,
|
||||
#[serde(default)]
|
||||
pub grep: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub list_directory: Option<String>,
|
||||
}
|
||||
|
||||
/// Convenience wrapper with flattened fields.
|
||||
pub struct LoadedConfig {
|
||||
pub model_name: Option<String>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
97
sunbeam/src/code/mod.rs
Normal file
97
sunbeam/src/code/mod.rs
Normal file
@@ -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<String>,
|
||||
/// Sol gRPC endpoint (default: from sunbeam config)
|
||||
#[arg(long)]
|
||||
endpoint: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn cmd_code(cmd: Option<CodeCommand>) -> 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<CodeCommand>) -> 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
131
sunbeam/src/code/project.rs
Normal file
131
sunbeam/src/code/project.rs
Normal file
@@ -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<String>,
|
||||
pub git_status: Option<String>,
|
||||
pub file_tree: Vec<String>,
|
||||
}
|
||||
|
||||
/// Discover project context from the working directory.
|
||||
pub fn discover_project(dir: &str) -> anyhow::Result<ProjectContext> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
284
sunbeam/src/code/tools.rs
Normal file
284
sunbeam/src/code/tools.rs
Normal file
@@ -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<String>,
|
||||
) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod cli;
|
||||
mod code;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
||||
Reference in New Issue
Block a user