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:
2026-03-23 11:57:24 +00:00
parent f3e67e589b
commit 02e4d7fb37
10 changed files with 838 additions and 4 deletions

View File

@@ -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"

View File

@@ -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(())
}

View File

@@ -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"

View File

@@ -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
View 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
View 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
View 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
View 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
View 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"));
}
}

View File

@@ -1,4 +1,5 @@
mod cli;
mod code;
#[tokio::main]
async fn main() {