A safe, simple, extensible, and fast agent harness
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Run ein-server as service (#27)

* Configure `ein-server` to run as a service on macOS

* Add uninstall process to TUI for server binaries and service

authored by

Mason Stallmo and committed by
GitHub
9789f19c 674b1775

+706 -81
+5 -13
Cargo.lock
··· 971 971 checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 972 972 973 973 [[package]] 974 - name = "ein" 975 - version = "0.1.6" 976 - dependencies = [ 977 - "anyhow", 978 - "clap", 979 - "ein-server", 980 - "ein-tui", 981 - "tokio", 982 - ] 983 - 984 - [[package]] 985 974 name = "ein-agent" 986 975 version = "0.1.6" 987 976 dependencies = [ ··· 1051 1040 "crossterm", 1052 1041 "dirs", 1053 1042 "ein-proto", 1043 + "flate2", 1054 1044 "notify", 1055 1045 "ratatui", 1046 + "reqwest", 1056 1047 "serde", 1057 1048 "serde_json", 1058 1049 "syntect", 1050 + "tar", 1059 1051 "tokio", 1060 1052 "tokio-stream", 1061 1053 "tonic", ··· 3050 3042 3051 3043 [[package]] 3052 3044 name = "reqwest" 3053 - version = "0.13.2" 3045 + version = "0.13.3" 3054 3046 source = "registry+https://github.com/rust-lang/crates.io-index" 3055 - checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" 3047 + checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" 3056 3048 dependencies = [ 3057 3049 "base64", 3058 3050 "bytes",
+1 -4
Cargo.toml
··· 1 1 [workspace] 2 2 members = [ 3 - "crates/ein", 4 3 "crates/ein-server", 5 4 "crates/ein-tui", 6 5 "crates/ein-proto", ··· 9 8 "packages/*", 10 9 ] 11 10 default-members = [ 12 - "crates/ein", 13 11 "crates/ein-server", 14 12 "crates/ein-tui", 15 13 "crates/ein-proto", ··· 37 35 "x86_64-unknown-linux-gnu", 38 36 ] 39 37 pr-run-mode = "plan" 40 - # Only distribute the meta-package; individual crates are excluded from releases. 41 - members = ["crates/ein"] 38 + members = ["crates/ein-tui", "crates/ein-server"] 42 39 # Allow manual edits to the generated workflow (we add a protoc install step). 43 40 allow-dirty = ["ci"] 44 41
+46 -10
crates/ein-agent/src/agents.rs
··· 768 768 769 769 let msgs = agent.messages(); 770 770 assert!( 771 - msgs[0].content.as_deref().unwrap_or("").starts_with("[Tool result truncated:"), 771 + msgs[0] 772 + .content 773 + .as_deref() 774 + .unwrap_or("") 775 + .starts_with("[Tool result truncated:"), 772 776 "old large tool result must be truncated" 773 777 ); 774 778 assert!( 775 - msgs[1].content.as_deref().unwrap_or("").starts_with("[Tool result truncated:"), 779 + msgs[1] 780 + .content 781 + .as_deref() 782 + .unwrap_or("") 783 + .starts_with("[Tool result truncated:"), 776 784 "old large tool result must be truncated" 777 785 ); 778 786 assert_eq!(msgs[2].content.as_deref(), Some("recent 1")); ··· 799 807 800 808 for msg in agent.messages() { 801 809 assert!( 802 - !msg.content.as_deref().unwrap_or("").starts_with("[Tool result truncated:"), 810 + !msg.content 811 + .as_deref() 812 + .unwrap_or("") 813 + .starts_with("[Tool result truncated:"), 803 814 "recent messages must not be truncated" 804 815 ); 805 816 } ··· 823 834 agent.truncate_old_tool_results(); 824 835 825 836 let msgs = agent.messages(); 826 - assert_eq!(msgs[0].content.as_deref(), Some(large.as_str()), "User must not be truncated"); 827 - assert_eq!(msgs[1].content.as_deref(), Some(large.as_str()), "System must not be truncated"); 837 + assert_eq!( 838 + msgs[0].content.as_deref(), 839 + Some(large.as_str()), 840 + "User must not be truncated" 841 + ); 842 + assert_eq!( 843 + msgs[1].content.as_deref(), 844 + Some(large.as_str()), 845 + "System must not be truncated" 846 + ); 828 847 } 829 848 830 849 #[test] ··· 899 918 }) 900 919 .with_event_handler(move |event| { 901 920 let cap = cap.clone(); 902 - async move { cap.lock().unwrap().push(event); } 921 + async move { 922 + cap.lock().unwrap().push(event); 923 + } 903 924 }) 904 925 .with_message_history(vec![user_msg("do stuff")]) 905 926 .build(); ··· 910 931 let deltas: Vec<&str> = events 911 932 .iter() 912 933 .filter_map(|e| { 913 - if let AgentEvent::ContentDelta(t) = e { Some(t.as_str()) } else { None } 934 + if let AgentEvent::ContentDelta(t) = e { 935 + Some(t.as_str()) 936 + } else { 937 + None 938 + } 914 939 }) 915 940 .collect(); 916 941 assert_eq!(deltas, vec![summary]); ··· 993 1018 }) 994 1019 .with_event_handler(move |event| { 995 1020 let cap = cap.clone(); 996 - async move { cap.lock().unwrap().push(event); } 1021 + async move { 1022 + cap.lock().unwrap().push(event); 1023 + } 997 1024 }) 998 1025 .build(); 999 1026 ··· 1001 1028 1002 1029 let events = captured.lock().unwrap(); 1003 1030 let usage = events.iter().find_map(|e| { 1004 - if let AgentEvent::TokenUsage { prompt_tokens, completion_tokens, total_tokens } = e { 1031 + if let AgentEvent::TokenUsage { 1032 + prompt_tokens, 1033 + completion_tokens, 1034 + total_tokens, 1035 + } = e 1036 + { 1005 1037 Some((*prompt_tokens, *completion_tokens, *total_tokens)) 1006 1038 } else { 1007 1039 None 1008 1040 } 1009 1041 }); 1010 - assert_eq!(usage, Some((10, 5, 15)), "TokenUsage event must carry correct totals"); 1042 + assert_eq!( 1043 + usage, 1044 + Some((10, 5, 15)), 1045 + "TokenUsage event must carry correct totals" 1046 + ); 1011 1047 } 1012 1048 1013 1049 #[tokio::test]
+1 -4
crates/ein-core/src/types.rs
··· 419 419 }; 420 420 let json = serde_json::to_string(&resp).unwrap(); 421 421 let decoded: CompletionResponse = serde_json::from_str(&json).unwrap(); 422 - assert_eq!( 423 - decoded.error.unwrap()["message"], 424 - "insufficient credits" 425 - ); 422 + assert_eq!(decoded.error.unwrap()["message"], "insufficient credits"); 426 423 } 427 424 }
+4
crates/ein-server/Cargo.toml
··· 11 11 name = "ein_server" 12 12 path = "src/lib.rs" 13 13 14 + [[bin]] 15 + name = "ein-server" 16 + path = "src/main.rs" 17 + 14 18 15 19 [dependencies] 16 20 anyhow = { workspace = true }
+7
crates/ein-tui/Cargo.toml
··· 11 11 name = "ein_tui" 12 12 path = "src/lib.rs" 13 13 14 + [[bin]] 15 + name = "ein" 16 + path = "src/main.rs" 17 + 14 18 15 19 [dependencies] 16 20 anyhow = { workspace = true } ··· 30 34 tracing = { workspace = true } 31 35 tracing-appender = "0.2" 32 36 tracing-subscriber = { workspace = true } 37 + reqwest = { version = "0.13.3", default-features = false, features = ["rustls"] } 38 + flate2 = "1.1.9" 39 + tar = "0.4.45"
+19
crates/ein-tui/src/app.rs
··· 30 30 PluginStatusLoaded(Vec<PluginSourceStatus>), 31 31 /// A plugin install RPC completed; carries success flag and a status message. 32 32 PluginInstallResult { success: bool, message: String }, 33 + /// Background uninstall task finished; carry the step log and outcome. 34 + UninstallComplete { success: bool, steps: Vec<String> }, 33 35 } 34 36 35 37 /// Whether the TUI currently has a live server connection. ··· 198 200 SessionPicker(SessionPickerState), 199 201 /// CWD access prompt, shown after choosing "New Session". 200 202 CwdPrompt(CwdState), 203 + /// Uninstall confirmation / progress / result, opened via `/uninstall`. 204 + UninstallConfirm(UninstallModalState), 201 205 } 202 206 203 207 // --------------------------------------------------------------------------- ··· 227 231 pub(crate) selected: usize, 228 232 /// One-shot channel back to `try_connect`; sends the chosen `SessionConfig`. 229 233 pub(crate) session_tx: oneshot::Sender<SessionConfig>, 234 + } 235 + 236 + // --------------------------------------------------------------------------- 237 + // Uninstall modal state 238 + // --------------------------------------------------------------------------- 239 + 240 + pub(crate) enum UninstallPhase { 241 + Confirm, 242 + Running, 243 + Done { success: bool }, 244 + } 245 + 246 + pub(crate) struct UninstallModalState { 247 + pub(crate) phase: UninstallPhase, 248 + pub(crate) log: Vec<String>, 230 249 } 231 250 232 251 /// State for the CWD access modal, shown only when "New Session" is chosen.
+365
crates/ein-tui/src/bootstrap.rs
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // Copyright 2026 Mason Stallmo 3 + 4 + //! Bootstrap logic: downloads `ein-server` on first run and registers it as a 5 + //! system service (macOS LaunchAgent or Linux systemd user service). 6 + 7 + // These items are only called from the #[cfg(not(debug_assertions))] block in 8 + // lib.rs, so they appear unused in debug builds. That's intentional. 9 + #![cfg_attr(debug_assertions, allow(dead_code))] 10 + 11 + use anyhow::{Context, Result}; 12 + use flate2::read::GzDecoder; 13 + use std::{ 14 + io, 15 + os::unix::fs::PermissionsExt, 16 + path::{Path, PathBuf}, 17 + }; 18 + use tar::Archive; 19 + use tokio::{fs, process::Command, task}; 20 + 21 + const GITHUB_REPO: &str = "mstallmo/ein"; 22 + 23 + /// Path where `ein` installs the server binary: `~/.ein/bin/ein-server`. 24 + pub fn server_bin_path() -> PathBuf { 25 + dirs::home_dir() 26 + .expect("home directory not found") 27 + .join(".ein") 28 + .join("bin") 29 + .join("ein-server") 30 + } 31 + 32 + /// Compile-time target triple used to select the right GitHub release asset. 33 + pub fn target_triple() -> &'static str { 34 + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 35 + return "aarch64-apple-darwin"; 36 + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] 37 + return "x86_64-apple-darwin"; 38 + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] 39 + return "aarch64-unknown-linux-gnu"; 40 + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] 41 + return "x86_64-unknown-linux-gnu"; 42 + #[allow(unreachable_code)] 43 + "" 44 + } 45 + 46 + /// Downloads the `ein-server` binary for the current platform from GitHub 47 + /// releases and writes it to `~/.ein/bin/ein-server` with executable permissions. 48 + pub async fn download_server(version: &str) -> Result<()> { 49 + let ver = version.trim_start_matches('v'); 50 + let tag = format!("v{ver}"); 51 + let triple = target_triple(); 52 + // cargo-dist names archives as "{package}-{triple}.tar.gz" (no version in filename). 53 + let archive_name = format!("ein-server-{triple}.tar.gz"); 54 + let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); 55 + 56 + let dest = server_bin_path(); 57 + fs::create_dir_all(dest.parent().unwrap()) 58 + .await 59 + .context("failed to create ~/.ein/bin")?; 60 + 61 + println!("Downloading {url}..."); 62 + 63 + let response = reqwest::get(&url) 64 + .await 65 + .with_context(|| format!("failed to fetch {url}"))?; 66 + 67 + if !response.status().is_success() { 68 + anyhow::bail!("download failed: HTTP {}", response.status()); 69 + } 70 + 71 + let bytes = response 72 + .bytes() 73 + .await 74 + .context("failed to read response body")?; 75 + 76 + let dest_clone = dest.clone(); 77 + task::spawn_blocking(move || extract_server(&bytes, &dest_clone)) 78 + .await 79 + .context("extraction task panicked")??; 80 + 81 + // Make the binary executable. 82 + let mut perms = fs::metadata(&dest).await?.permissions(); 83 + perms.set_mode(0o755); 84 + fs::set_permissions(&dest, perms).await?; 85 + 86 + println!("ein-server installed to {}", dest.display()); 87 + Ok(()) 88 + } 89 + 90 + /// Extracts the `ein-server` binary from a tar.gz archive into `dest`. 91 + fn extract_server(bytes: &[u8], dest: &Path) -> Result<()> { 92 + let gz = GzDecoder::new(io::Cursor::new(bytes)); 93 + let mut archive = Archive::new(gz); 94 + 95 + for entry in archive 96 + .entries() 97 + .context("failed to read archive entries")? 98 + { 99 + let mut entry = entry.context("corrupt archive entry")?; 100 + let entry_path = entry.path().context("entry has no path")?; 101 + 102 + // The archive contains exactly one file: the `ein-server` binary. 103 + // Accept it regardless of any leading directory component. 104 + let file_name = entry_path 105 + .file_name() 106 + .and_then(|n| n.to_str()) 107 + .unwrap_or(""); 108 + 109 + if file_name == "ein-server" { 110 + let mut file = std::fs::File::create(dest) 111 + .with_context(|| format!("failed to create {}", dest.display()))?; 112 + io::copy(&mut entry, &mut file).context("failed to write ein-server")?; 113 + return Ok(()); 114 + } 115 + } 116 + 117 + anyhow::bail!("ein-server binary not found in archive") 118 + } 119 + 120 + // --------------------------------------------------------------------------- 121 + // Service registration 122 + // --------------------------------------------------------------------------- 123 + 124 + /// Ensures `ein-server` is registered as a system service. 125 + /// 126 + /// On macOS, installs a LaunchAgent plist and loads it. 127 + /// On Linux, writes a systemd user unit and enables it. 128 + /// On other platforms, does nothing (the TUI's retry loop handles reconnects). 129 + pub async fn ensure_service_installed() -> Result<()> { 130 + #[cfg(target_os = "macos")] 131 + return ensure_launchagent_installed().await; 132 + 133 + #[cfg(target_os = "linux")] 134 + return ensure_systemd_installed().await; 135 + 136 + #[cfg(not(any(target_os = "macos", target_os = "linux")))] 137 + Ok(()) 138 + } 139 + 140 + // --------------------------------------------------------------------------- 141 + // Uninstall 142 + // --------------------------------------------------------------------------- 143 + 144 + /// Stops and removes the `ein-server` service and binary installed by 145 + /// [`ensure_service_installed`] and [`download_server`]. 146 + /// 147 + /// Returns a list of completed step descriptions for display in the TUI. 148 + /// User config and session data in `~/.ein/` are left intact. 149 + pub async fn uninstall() -> Result<Vec<String>> { 150 + let mut steps: Vec<String> = Vec::new(); 151 + #[cfg(target_os = "macos")] 152 + uninstall_launchagent(&mut steps).await?; 153 + #[cfg(target_os = "linux")] 154 + uninstall_systemd(&mut steps).await?; 155 + remove_server_binary(&mut steps).await?; 156 + Ok(steps) 157 + } 158 + 159 + async fn remove_server_binary(steps: &mut Vec<String>) -> Result<()> { 160 + let path = server_bin_path(); 161 + if path.exists() { 162 + fs::remove_file(&path) 163 + .await 164 + .with_context(|| format!("failed to remove {}", path.display()))?; 165 + steps.push(format!("Removed {}", path.display())); 166 + } 167 + Ok(()) 168 + } 169 + 170 + #[cfg(target_os = "macos")] 171 + async fn uninstall_launchagent(steps: &mut Vec<String>) -> Result<()> { 172 + let plist = launchagent_plist_path(); 173 + // Ignore errors — the service may already be stopped/unloaded. 174 + let _ = Command::new("launchctl") 175 + .args(["unload", plist.to_str().unwrap_or("")]) 176 + .output() 177 + .await; 178 + if plist.exists() { 179 + fs::remove_file(&plist) 180 + .await 181 + .with_context(|| format!("failed to remove {}", plist.display()))?; 182 + steps.push(format!("Removed LaunchAgent ({})", LAUNCH_AGENT_LABEL)); 183 + } 184 + Ok(()) 185 + } 186 + 187 + #[cfg(target_os = "linux")] 188 + async fn uninstall_systemd(steps: &mut Vec<String>) -> Result<()> { 189 + let unit = systemd_unit_path(); 190 + let _ = Command::new("systemctl") 191 + .args(["--user", "stop", SYSTEMD_SERVICE_NAME]) 192 + .output() 193 + .await; 194 + let _ = Command::new("systemctl") 195 + .args(["--user", "disable", SYSTEMD_SERVICE_NAME]) 196 + .output() 197 + .await; 198 + if unit.exists() { 199 + fs::remove_file(&unit) 200 + .await 201 + .with_context(|| format!("failed to remove {}", unit.display()))?; 202 + steps.push(format!("Removed systemd user service ({})", SYSTEMD_SERVICE_NAME)); 203 + } 204 + let _ = Command::new("systemctl") 205 + .args(["--user", "daemon-reload"]) 206 + .output() 207 + .await; 208 + Ok(()) 209 + } 210 + 211 + // --------------------------------------------------------------------------- 212 + // macOS LaunchAgent 213 + // --------------------------------------------------------------------------- 214 + 215 + #[cfg(target_os = "macos")] 216 + const LAUNCH_AGENT_LABEL: &str = "com.ein.server"; 217 + 218 + #[cfg(target_os = "macos")] 219 + fn launchagent_plist_path() -> PathBuf { 220 + dirs::home_dir() 221 + .expect("home directory not found") 222 + .join("Library") 223 + .join("LaunchAgents") 224 + .join(format!("{LAUNCH_AGENT_LABEL}.plist")) 225 + } 226 + 227 + #[cfg(target_os = "macos")] 228 + async fn ensure_launchagent_installed() -> Result<()> { 229 + // Check if already loaded. 230 + let status = Command::new("launchctl") 231 + .args(["list", LAUNCH_AGENT_LABEL]) 232 + .output() 233 + .await 234 + .context("launchctl not found")?; 235 + 236 + if status.status.success() { 237 + return Ok(()); // Already running. 238 + } 239 + 240 + let plist_path = launchagent_plist_path(); 241 + let bin = server_bin_path(); 242 + let log = dirs::home_dir() 243 + .expect("home directory not found") 244 + .join(".ein") 245 + .join("server.log"); 246 + 247 + let plist = format!( 248 + r#"<?xml version="1.0" encoding="UTF-8"?> 249 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 250 + <plist version="1.0"> 251 + <dict> 252 + <key>Label</key> 253 + <string>{LAUNCH_AGENT_LABEL}</string> 254 + <key>ProgramArguments</key> 255 + <array> 256 + <string>{bin}</string> 257 + </array> 258 + <key>RunAtLoad</key> 259 + <true/> 260 + <key>KeepAlive</key> 261 + <true/> 262 + <key>StandardOutPath</key> 263 + <string>{log}</string> 264 + <key>StandardErrorPath</key> 265 + <string>{log}</string> 266 + </dict> 267 + </plist> 268 + "#, 269 + LAUNCH_AGENT_LABEL = LAUNCH_AGENT_LABEL, 270 + bin = bin.display(), 271 + log = log.display(), 272 + ); 273 + 274 + fs::create_dir_all(plist_path.parent().unwrap()) 275 + .await 276 + .context("failed to create LaunchAgents directory")?; 277 + fs::write(&plist_path, plist) 278 + .await 279 + .context("failed to write plist")?; 280 + 281 + let output = Command::new("launchctl") 282 + .args(["load", plist_path.to_str().unwrap()]) 283 + .output() 284 + .await 285 + .context("failed to run launchctl load")?; 286 + 287 + if !output.status.success() { 288 + let stderr = String::from_utf8_lossy(&output.stderr); 289 + anyhow::bail!("launchctl load failed: {stderr}"); 290 + } 291 + 292 + println!("ein-server registered as LaunchAgent ({LAUNCH_AGENT_LABEL})"); 293 + Ok(()) 294 + } 295 + 296 + // --------------------------------------------------------------------------- 297 + // Linux systemd user service 298 + // --------------------------------------------------------------------------- 299 + 300 + #[cfg(target_os = "linux")] 301 + const SYSTEMD_SERVICE_NAME: &str = "ein-server"; 302 + 303 + #[cfg(target_os = "linux")] 304 + fn systemd_unit_path() -> PathBuf { 305 + dirs::home_dir() 306 + .expect("home directory not found") 307 + .join(".config") 308 + .join("systemd") 309 + .join("user") 310 + .join(format!("{SYSTEMD_SERVICE_NAME}.service")) 311 + } 312 + 313 + #[cfg(target_os = "linux")] 314 + async fn ensure_systemd_installed() -> Result<()> { 315 + // Check if already enabled. 316 + let status = Command::new("systemctl") 317 + .args(["--user", "is-enabled", SYSTEMD_SERVICE_NAME]) 318 + .output() 319 + .await 320 + .context("systemctl not found")?; 321 + 322 + if status.status.success() { 323 + return Ok(()); // Already enabled. 324 + } 325 + 326 + let unit_path = systemd_unit_path(); 327 + let bin = server_bin_path(); 328 + 329 + let unit = format!( 330 + "[Unit]\nDescription=Ein server\n\n[Service]\nExecStart={bin}\nRestart=always\n\n[Install]\nWantedBy=default.target\n", 331 + bin = bin.display(), 332 + ); 333 + 334 + fs::create_dir_all(unit_path.parent().unwrap()) 335 + .await 336 + .context("failed to create systemd user directory")?; 337 + fs::write(&unit_path, unit) 338 + .await 339 + .context("failed to write systemd unit")?; 340 + 341 + let reload = Command::new("systemctl") 342 + .args(["--user", "daemon-reload"]) 343 + .output() 344 + .await 345 + .context("failed to run systemctl daemon-reload")?; 346 + 347 + if !reload.status.success() { 348 + let stderr = String::from_utf8_lossy(&reload.stderr); 349 + anyhow::bail!("systemctl daemon-reload failed: {stderr}"); 350 + } 351 + 352 + let enable = Command::new("systemctl") 353 + .args(["--user", "enable", "--now", SYSTEMD_SERVICE_NAME]) 354 + .output() 355 + .await 356 + .context("failed to run systemctl enable")?; 357 + 358 + if !enable.status.success() { 359 + let stderr = String::from_utf8_lossy(&enable.stderr); 360 + anyhow::bail!("systemctl enable failed: {stderr}"); 361 + } 362 + 363 + println!("ein-server registered as systemd user service ({SYSTEMD_SERVICE_NAME})"); 364 + Ok(()) 365 + }
+16 -4
crates/ein-tui/src/connection.rs
··· 280 280 let mut plugin_configs = HashMap::new(); 281 281 plugin_configs.insert( 282 282 "ein_openrouter".to_string(), 283 - PluginConfig { params, ..Default::default() }, 283 + PluginConfig { 284 + params, 285 + ..Default::default() 286 + }, 284 287 ); 285 288 286 - let cfg = ClientConfig { plugin_configs, ..Default::default() }; 289 + let cfg = ClientConfig { 290 + plugin_configs, 291 + ..Default::default() 292 + }; 287 293 let proto = to_proto_session_config(&cfg, "id".to_string()); 288 294 289 295 let pc = &proto.plugin_configs["ein_openrouter"]; ··· 304 310 }, 305 311 ); 306 312 307 - let cfg = ClientConfig { plugin_configs, ..Default::default() }; 313 + let cfg = ClientConfig { 314 + plugin_configs, 315 + ..Default::default() 316 + }; 308 317 let proto = to_proto_session_config(&cfg, "id".to_string()); 309 318 310 319 let pc = &proto.plugin_configs["Bash"]; ··· 319 328 plugin_configs.insert("Bash".to_string(), PluginConfig::default()); 320 329 plugin_configs.insert("Read".to_string(), PluginConfig::default()); 321 330 322 - let cfg = ClientConfig { plugin_configs, ..Default::default() }; 331 + let cfg = ClientConfig { 332 + plugin_configs, 333 + ..Default::default() 334 + }; 323 335 let proto = to_proto_session_config(&cfg, "id".to_string()); 324 336 325 337 assert_eq!(proto.plugin_configs.len(), 3);
+52 -2
crates/ein-tui/src/input.rs
··· 6 6 use tracing::{debug, info, warn}; 7 7 8 8 use crate::app::{ 9 - App, CwdState, DisplayMessage, Modal, SessionPickerState, SetupWizardState, WizardStep, 9 + App, CwdState, DisplayMessage, Modal, SessionPickerState, SetupWizardState, UninstallModalState, 10 + UninstallPhase, WizardStep, 10 11 }; 11 12 use crate::connection::to_proto_session_config; 12 13 ··· 56 57 name: "/setup", 57 58 description: "Run the first-time setup wizard", 58 59 }, 60 + CommandDef { 61 + name: "/uninstall", 62 + description: "Stop and remove the ein-server service and binary", 63 + }, 59 64 ]; 60 65 61 66 /// Recomputes `autocomplete_matches` and `autocomplete_active` based on the ··· 102 107 OpenSetupWizard, 103 108 /// Setup wizard saved config; trigger an immediate reconnect. 104 109 SetupComplete, 110 + /// User confirmed uninstall; spawn the background removal task. 111 + RunUninstall, 105 112 } 106 113 107 114 // --------------------------------------------------------------------------- ··· 124 131 Some(Modal::PluginManager(_)) => handle_plugin_modal_key(app, key), 125 132 Some(Modal::SessionPicker(_)) => handle_session_picker_key(app, key).await, 126 133 Some(Modal::CwdPrompt(_)) => handle_cwd_modal_key(app, key), 134 + Some(Modal::UninstallConfirm(_)) => handle_uninstall_modal_key(app, key), 127 135 None => handle_normal_key(app, key).await, 128 136 } 129 137 } ··· 414 422 KeyAction::Continue 415 423 } 416 424 425 + fn handle_uninstall_modal_key(app: &mut App, key: KeyEvent) -> KeyAction { 426 + let phase = match &app.modal { 427 + Some(Modal::UninstallConfirm(s)) => match s.phase { 428 + UninstallPhase::Confirm => 0, 429 + UninstallPhase::Running => 1, 430 + UninstallPhase::Done { .. } => 2, 431 + }, 432 + _ => return KeyAction::Continue, 433 + }; 434 + 435 + match phase { 436 + 0 => match key.code { 437 + KeyCode::Char('y') | KeyCode::Char('Y') => { 438 + if let Some(Modal::UninstallConfirm(s)) = &mut app.modal { 439 + s.phase = UninstallPhase::Running; 440 + } 441 + KeyAction::RunUninstall 442 + } 443 + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { 444 + app.modal = None; 445 + KeyAction::Continue 446 + } 447 + _ => KeyAction::Continue, 448 + }, 449 + 1 => KeyAction::Continue, // block input while running 450 + _ => { 451 + // Done phase — any key closes the modal and returns to main screen. 452 + app.modal = None; 453 + KeyAction::Continue 454 + } 455 + } 456 + } 457 + 417 458 async fn handle_normal_key(app: &mut App, key: KeyEvent) -> KeyAction { 418 459 match key.code { 419 460 KeyCode::Enter => { ··· 481 522 "/plugins" => return KeyAction::OpenPluginModal, 482 523 "/sessions" => return KeyAction::OpenSessionPicker, 483 524 "/setup" => return KeyAction::OpenSetupWizard, 525 + "/uninstall" => { 526 + app.modal = Some(Modal::UninstallConfirm(UninstallModalState { 527 + phase: UninstallPhase::Confirm, 528 + log: vec![], 529 + })); 530 + return KeyAction::Continue; 531 + } 484 532 _ => { 485 533 // Reject unrecognized slash commands — display a local error, do not send to server. 486 534 if text.starts_with('/') { ··· 913 961 let action = handle_key_event(&mut app, key(KeyCode::Enter)).await; 914 962 assert!(matches!(action, KeyAction::Continue)); 915 963 assert!( 916 - app.messages.iter().any(|m| matches!(m, DisplayMessage::Error(_))), 964 + app.messages 965 + .iter() 966 + .any(|m| matches!(m, DisplayMessage::Error(_))), 917 967 "unknown slash command must add an Error message" 918 968 ); 919 969 }
+57 -1
crates/ein-tui/src/lib.rs
··· 7 7 //! meta-package binary can share the same entry-point without duplicating code. 8 8 9 9 mod app; 10 + mod bootstrap; 10 11 mod config; 11 12 mod connection; 12 13 mod input; ··· 14 15 15 16 use crate::app::{ 16 17 App, AppEvent, ConnectionStatus, CwdState, DisplayMessage, Modal, PluginModalState, 17 - SessionPickerState, SetupWizardState, 18 + SessionPickerState, SetupWizardState, UninstallPhase, 18 19 }; 19 20 use crate::config::load_or_create_config; 20 21 use crate::connection::{ ··· 96 97 }; 97 98 98 99 info!(server_addr = %args.server_addr, "ein-tui starting"); 100 + 101 + // In release builds: download ein-server if absent, then register it as a 102 + // system service. Runs before raw mode so stdout is visible for progress. 103 + #[cfg(not(debug_assertions))] 104 + { 105 + let bin = bootstrap::server_bin_path(); 106 + if !bin.exists() { 107 + println!("Downloading ein-server {}...", env!("CARGO_PKG_VERSION")); 108 + bootstrap::download_server(env!("CARGO_PKG_VERSION")).await?; 109 + } 110 + bootstrap::ensure_service_installed().await?; 111 + } 99 112 100 113 // Load (or create) the client config before opening the gRPC session. 101 114 let cfg = load_or_create_config()?; ··· 302 315 KeyAction::OpenSetupWizard => { 303 316 app.modal = Some(Modal::SetupWizard(SetupWizardState::new())); 304 317 } 318 + KeyAction::RunUninstall => { 319 + let tx = event_tx.clone(); 320 + tokio::spawn(async move { 321 + #[cfg(not(debug_assertions))] 322 + { 323 + match bootstrap::uninstall().await { 324 + Ok(steps) => { 325 + let _ = tx 326 + .send(AppEvent::UninstallComplete { 327 + success: true, 328 + steps, 329 + }) 330 + .await; 331 + } 332 + Err(e) => { 333 + let _ = tx 334 + .send(AppEvent::UninstallComplete { 335 + success: false, 336 + steps: vec![format!("Error: {e}")], 337 + }) 338 + .await; 339 + } 340 + } 341 + } 342 + #[cfg(debug_assertions)] 343 + { 344 + let _ = tx 345 + .send(AppEvent::UninstallComplete { 346 + success: true, 347 + steps: vec![ 348 + "(debug build — service removal skipped)".to_string() 349 + ], 350 + }) 351 + .await; 352 + } 353 + }); 354 + } 305 355 KeyAction::SetupComplete => { 306 356 app.prompt_tx = None; 307 357 app.connection_status = ConnectionStatus::Connecting; ··· 415 465 source.installed = true; 416 466 } 417 467 } 468 + } 469 + } 470 + AppEvent::UninstallComplete { success, steps } => { 471 + if let Some(Modal::UninstallConfirm(s)) = &mut app.modal { 472 + s.phase = UninstallPhase::Done { success }; 473 + s.log = steps; 418 474 } 419 475 } 420 476 }
+102 -1
crates/ein-tui/src/render.rs
··· 19 19 20 20 use crate::app::{ 21 21 App, ConnectionStatus, DisplayMessage, Modal, PROVIDERS, PluginModalState, SessionPickerState, 22 - SetupWizardState, WizardStep, 22 + SetupWizardState, UninstallModalState, UninstallPhase, WizardStep, 23 23 }; 24 24 use crate::input::COMMANDS; 25 25 ··· 321 321 } 322 322 Modal::CwdPrompt(cwd_state) => { 323 323 render_cwd_modal(&cwd_state.cwd, frame); 324 + } 325 + Modal::UninstallConfirm(state) => { 326 + render_uninstall_modal(state, app.tick, frame); 324 327 } 325 328 } 326 329 } ··· 751 754 Span::styled(" Deny", Style::default().fg(MUTED_COLOR)), 752 755 ]), 753 756 ]; 757 + 758 + frame.render_widget(Paragraph::new(lines), inner); 759 + } 760 + 761 + fn render_uninstall_modal(state: &UninstallModalState, tick: u64, frame: &mut Frame) { 762 + // Height: 2 borders + blank line + content lines + blank + hint 763 + let content_lines = match &state.phase { 764 + UninstallPhase::Confirm => 4u16, 765 + UninstallPhase::Running => 1u16, 766 + UninstallPhase::Done { .. } => state.log.len().max(1) as u16 + 2, 767 + }; 768 + let modal_height = 2 + 1 + content_lines; 769 + let modal_width = (frame.area().width * 7 / 10).max(60).min(frame.area().width); 770 + let area = centered_rect(modal_width, modal_height, frame.area()); 771 + 772 + frame.render_widget(Clear, area); 773 + 774 + let (title, border_color) = match &state.phase { 775 + UninstallPhase::Confirm => (" Uninstall ein-server? ", DISCONNECTED_COLOR), 776 + UninstallPhase::Running => (" Uninstalling… ", MUTED_COLOR), 777 + UninstallPhase::Done { success: true } => (" Uninstalled ", Color::Green), 778 + UninstallPhase::Done { success: false } => (" Uninstall failed ", DISCONNECTED_COLOR), 779 + }; 780 + 781 + let block = Block::default() 782 + .title(title) 783 + .borders(Borders::ALL) 784 + .border_style(Style::default().fg(border_color)); 785 + 786 + let inner = block.inner(area); 787 + frame.render_widget(block, area); 788 + 789 + const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 790 + 791 + let mut lines = vec![Line::raw("")]; 792 + 793 + match &state.phase { 794 + UninstallPhase::Confirm => { 795 + lines.push(Line::from(Span::styled( 796 + " Stop the service and remove the server binary.", 797 + Style::default().fg(AUTOCOMPLETE_TOP_COLOR), 798 + ))); 799 + lines.push(Line::from(Span::styled( 800 + " Config and sessions in ~/.ein/ will be preserved.", 801 + Style::default().fg(MUTED_COLOR), 802 + ))); 803 + lines.push(Line::raw("")); 804 + lines.push(Line::from(vec![ 805 + Span::styled( 806 + " [Y]", 807 + Style::default() 808 + .fg(Color::Green) 809 + .add_modifier(Modifier::BOLD), 810 + ), 811 + Span::styled(" Confirm ", Style::default().fg(MUTED_COLOR)), 812 + Span::styled( 813 + "[N]", 814 + Style::default() 815 + .fg(DISCONNECTED_COLOR) 816 + .add_modifier(Modifier::BOLD), 817 + ), 818 + Span::styled(" Cancel", Style::default().fg(MUTED_COLOR)), 819 + ])); 820 + } 821 + UninstallPhase::Running => { 822 + lines.push(Line::from(vec![ 823 + Span::styled( 824 + format!(" {} ", SPINNER[tick as usize % SPINNER.len()]), 825 + Style::default().fg(THINKING_COLOR), 826 + ), 827 + Span::styled( 828 + "Uninstalling…", 829 + Style::default() 830 + .fg(MUTED_COLOR) 831 + .add_modifier(Modifier::ITALIC), 832 + ), 833 + ])); 834 + } 835 + UninstallPhase::Done { success } => { 836 + for step in &state.log { 837 + lines.push(Line::from(Span::styled( 838 + format!(" {step}"), 839 + Style::default().fg(if *success { 840 + Color::Green 841 + } else { 842 + DISCONNECTED_COLOR 843 + }), 844 + ))); 845 + } 846 + lines.push(Line::raw("")); 847 + lines.push(Line::from(Span::styled( 848 + " Press any key to dismiss", 849 + Style::default() 850 + .fg(MUTED_COLOR) 851 + .add_modifier(Modifier::ITALIC), 852 + ))); 853 + } 854 + } 754 855 755 856 frame.render_widget(Paragraph::new(lines), inner); 756 857 }
-24
crates/ein/Cargo.toml
··· 1 - [package] 2 - name = "ein" 3 - version.workspace = true 4 - edition.workspace = true 5 - authors.workspace = true 6 - license.workspace = true 7 - description = "AI agent framework — installs both ein-tui and ein-server" 8 - repository.workspace = true 9 - homepage.workspace = true 10 - 11 - [dependencies] 12 - anyhow = { workspace = true } 13 - clap = { version = "4", features = ["derive"] } 14 - ein-server = { path = "../ein-server" } 15 - ein-tui = { path = "../ein-tui" } 16 - tokio = { workspace = true } 17 - 18 - [[bin]] 19 - name = "ein-server" 20 - path = "src/bin/ein_server.rs" 21 - 22 - [[bin]] 23 - name = "ein-tui" 24 - path = "src/bin/ein_tui.rs"
crates/ein/src/bin/ein_server.rs crates/ein-server/src/main.rs
crates/ein/src/bin/ein_tui.rs crates/ein-tui/src/main.rs
+7 -9
packages/ein_ollama/src/lib.rs
··· 70 70 )) 71 71 } 72 72 402 => { 73 - let msg = 74 - extract_api_error(body).unwrap_or_else(|| "Payment required".to_owned()); 73 + let msg = extract_api_error(body).unwrap_or_else(|| "Payment required".to_owned()); 75 74 Some(anyhow!("{msg}")) 76 75 } 77 76 404 => { 78 - let msg = 79 - extract_api_error(body).unwrap_or_else(|| "Model not found".to_owned()); 77 + let msg = extract_api_error(body).unwrap_or_else(|| "Model not found".to_owned()); 80 78 Some(anyhow!( 81 79 "{msg}\n\n\ 82 80 The model may not be downloaded yet. Run:\n\ ··· 186 184 #[test] 187 185 fn extract_api_error_present() { 188 186 let body = r#"{"error": {"message": "model not loaded", "type": "not_found"}}"#; 189 - assert_eq!( 190 - extract_api_error(body).as_deref(), 191 - Some("model not loaded") 192 - ); 187 + assert_eq!(extract_api_error(body).as_deref(), Some("model not loaded")); 193 188 } 194 189 195 190 #[test] ··· 282 277 fn map_http_error_404_suggests_ollama_pull() { 283 278 let err = map_http_error(404, "{}", "mistral").unwrap(); 284 279 let msg = err.to_string(); 285 - assert!(msg.contains("ollama pull"), "expected 'ollama pull' in: {msg}"); 280 + assert!( 281 + msg.contains("ollama pull"), 282 + "expected 'ollama pull' in: {msg}" 283 + ); 286 284 assert!(msg.contains("mistral"), "expected model name in: {msg}"); 287 285 } 288 286
+6 -5
packages/ein_openrouter/src/lib.rs
··· 38 38 )) 39 39 } 40 40 402 => { 41 - let msg = 42 - extract_api_error(body).unwrap_or_else(|| "Insufficient credits".to_owned()); 41 + let msg = extract_api_error(body).unwrap_or_else(|| "Insufficient credits".to_owned()); 43 42 Some(anyhow!( 44 43 "{msg}\n\nCheck your account balance at openrouter.ai." 45 44 )) 46 45 } 47 46 404 => { 48 - let msg = 49 - extract_api_error(body).unwrap_or_else(|| "Resource not found".to_owned()); 47 + let msg = extract_api_error(body).unwrap_or_else(|| "Resource not found".to_owned()); 50 48 Some(anyhow!("{msg}")) 51 49 } 52 50 s if !(200..300).contains(&s) => { ··· 194 192 fn map_http_error_402_mentions_credits_and_balance() { 195 193 let err = map_http_error(402, "{}").unwrap(); 196 194 let msg = err.to_string(); 197 - assert!(msg.contains("openrouter.ai"), "expected openrouter.ai link in: {msg}"); 195 + assert!( 196 + msg.contains("openrouter.ai"), 197 + "expected openrouter.ai link in: {msg}" 198 + ); 198 199 } 199 200 200 201 #[test]
+8 -2
packages/ein_read/src/lib.rs
··· 192 192 #[test] 193 193 fn read_truncation_header_shows_range_and_total() { 194 194 // 10 lines, read only first 4 195 - let content = (1..=10).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n"); 195 + let content = (1..=10) 196 + .map(|i| format!("line{i}")) 197 + .collect::<Vec<_>>() 198 + .join("\n"); 196 199 let f = write_temp(&content); 197 200 let out = call(f.path().to_str().unwrap(), None, Some(4)).unwrap(); 198 201 assert!(out.starts_with("Lines 1-4 of 10"), "got: {out}"); ··· 201 204 202 205 #[test] 203 206 fn read_truncation_header_reflects_offset() { 204 - let content = (1..=10).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n"); 207 + let content = (1..=10) 208 + .map(|i| format!("line{i}")) 209 + .collect::<Vec<_>>() 210 + .join("\n"); 205 211 let f = write_temp(&content); 206 212 let out = call(f.path().to_str().unwrap(), Some(3), Some(3)).unwrap(); 207 213 // offset=3, limit=3 → lines 4-6 (1-based), 4 remain → header
+10 -2
packages/ein_write/src/lib.rs
··· 134 134 let dir = TempDir::new().unwrap(); 135 135 let path = dir.path().join("f.txt"); 136 136 let result = call(path.to_str().unwrap(), "abc").unwrap(); 137 - assert!(result.content.contains('3'), "expected byte count in: {}", result.content); 138 - assert!(result.content.contains(path.to_str().unwrap()), "expected path in: {}", result.content); 137 + assert!( 138 + result.content.contains('3'), 139 + "expected byte count in: {}", 140 + result.content 141 + ); 142 + assert!( 143 + result.content.contains(path.to_str().unwrap()), 144 + "expected path in: {}", 145 + result.content 146 + ); 139 147 } 140 148 }