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"
|
description = "Shared protobuf definitions for Sunbeam gRPC services"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tonic = "0.13"
|
tonic = "0.14"
|
||||||
prost = "0.13"
|
tonic-prost = "0.14"
|
||||||
|
prost = "0.14"
|
||||||
|
|
||||||
[build-dependencies]
|
[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>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tonic_build::compile_protos("proto/code.proto")?;
|
tonic_prost_build::compile_protos("proto/code.proto")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,18 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sunbeam-sdk = { path = "../sunbeam-sdk", features = ["all", "cli"] }
|
sunbeam-sdk = { path = "../sunbeam-sdk", features = ["all", "cli"] }
|
||||||
|
sunbeam-proto = { path = "../sunbeam-proto" }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
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>,
|
action: Option<PmAction>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Terminal coding agent powered by Sol.
|
||||||
|
Code {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: Option<crate::code::CodeCommand>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Self-update from latest mainline commit.
|
/// Self-update from latest mainline commit.
|
||||||
Update,
|
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::Update) => sunbeam_sdk::update::cmd_update().await,
|
||||||
|
|
||||||
Some(Verb::Version) => {
|
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 cli;
|
||||||
|
mod code;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user