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
118 lines
3.3 KiB
Rust
118 lines
3.3 KiB
Rust
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");
|
|
}
|
|
}
|