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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user