diff --git a/Cargo.lock b/Cargo.lock index dfe5b38..ee1da23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,17 +970,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "ein" -version = "0.1.6" -dependencies = [ - "anyhow", - "clap", - "ein-server", - "ein-tui", - "tokio", -] - [[package]] name = "ein-agent" version = "0.1.6" @@ -1051,11 +1040,14 @@ dependencies = [ "crossterm", "dirs", "ein-proto", + "flate2", "notify", "ratatui", + "reqwest", "serde", "serde_json", "syntect", + "tar", "tokio", "tokio-stream", "tonic", @@ -3050,9 +3042,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 459d730..a4ba5a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,5 @@ [workspace] members = [ - "crates/ein", "crates/ein-server", "crates/ein-tui", "crates/ein-proto", @@ -9,7 +8,6 @@ members = [ "packages/*", ] default-members = [ - "crates/ein", "crates/ein-server", "crates/ein-tui", "crates/ein-proto", @@ -37,8 +35,7 @@ targets = [ "x86_64-unknown-linux-gnu", ] pr-run-mode = "plan" -# Only distribute the meta-package; individual crates are excluded from releases. -members = ["crates/ein"] +members = ["crates/ein-tui", "crates/ein-server"] # Allow manual edits to the generated workflow (we add a protoc install step). allow-dirty = ["ci"] diff --git a/crates/ein-agent/src/agents.rs b/crates/ein-agent/src/agents.rs index 80ba738..4e48a3a 100644 --- a/crates/ein-agent/src/agents.rs +++ b/crates/ein-agent/src/agents.rs @@ -768,11 +768,19 @@ mod tests { let msgs = agent.messages(); assert!( - msgs[0].content.as_deref().unwrap_or("").starts_with("[Tool result truncated:"), + msgs[0] + .content + .as_deref() + .unwrap_or("") + .starts_with("[Tool result truncated:"), "old large tool result must be truncated" ); assert!( - msgs[1].content.as_deref().unwrap_or("").starts_with("[Tool result truncated:"), + msgs[1] + .content + .as_deref() + .unwrap_or("") + .starts_with("[Tool result truncated:"), "old large tool result must be truncated" ); assert_eq!(msgs[2].content.as_deref(), Some("recent 1")); @@ -799,7 +807,10 @@ mod tests { for msg in agent.messages() { assert!( - !msg.content.as_deref().unwrap_or("").starts_with("[Tool result truncated:"), + !msg.content + .as_deref() + .unwrap_or("") + .starts_with("[Tool result truncated:"), "recent messages must not be truncated" ); } @@ -823,8 +834,16 @@ mod tests { agent.truncate_old_tool_results(); let msgs = agent.messages(); - assert_eq!(msgs[0].content.as_deref(), Some(large.as_str()), "User must not be truncated"); - assert_eq!(msgs[1].content.as_deref(), Some(large.as_str()), "System must not be truncated"); + assert_eq!( + msgs[0].content.as_deref(), + Some(large.as_str()), + "User must not be truncated" + ); + assert_eq!( + msgs[1].content.as_deref(), + Some(large.as_str()), + "System must not be truncated" + ); } #[test] @@ -899,7 +918,9 @@ mod tests { }) .with_event_handler(move |event| { let cap = cap.clone(); - async move { cap.lock().unwrap().push(event); } + async move { + cap.lock().unwrap().push(event); + } }) .with_message_history(vec![user_msg("do stuff")]) .build(); @@ -910,7 +931,11 @@ mod tests { let deltas: Vec<&str> = events .iter() .filter_map(|e| { - if let AgentEvent::ContentDelta(t) = e { Some(t.as_str()) } else { None } + if let AgentEvent::ContentDelta(t) = e { + Some(t.as_str()) + } else { + None + } }) .collect(); assert_eq!(deltas, vec![summary]); @@ -993,7 +1018,9 @@ mod tests { }) .with_event_handler(move |event| { let cap = cap.clone(); - async move { cap.lock().unwrap().push(event); } + async move { + cap.lock().unwrap().push(event); + } }) .build(); @@ -1001,13 +1028,22 @@ mod tests { let events = captured.lock().unwrap(); let usage = events.iter().find_map(|e| { - if let AgentEvent::TokenUsage { prompt_tokens, completion_tokens, total_tokens } = e { + if let AgentEvent::TokenUsage { + prompt_tokens, + completion_tokens, + total_tokens, + } = e + { Some((*prompt_tokens, *completion_tokens, *total_tokens)) } else { None } }); - assert_eq!(usage, Some((10, 5, 15)), "TokenUsage event must carry correct totals"); + assert_eq!( + usage, + Some((10, 5, 15)), + "TokenUsage event must carry correct totals" + ); } #[tokio::test] diff --git a/crates/ein-core/src/types.rs b/crates/ein-core/src/types.rs index f44045f..59c1121 100644 --- a/crates/ein-core/src/types.rs +++ b/crates/ein-core/src/types.rs @@ -419,9 +419,6 @@ mod tests { }; let json = serde_json::to_string(&resp).unwrap(); let decoded: CompletionResponse = serde_json::from_str(&json).unwrap(); - assert_eq!( - decoded.error.unwrap()["message"], - "insufficient credits" - ); + assert_eq!(decoded.error.unwrap()["message"], "insufficient credits"); } } diff --git a/crates/ein-server/Cargo.toml b/crates/ein-server/Cargo.toml index 7180a36..fe50d45 100644 --- a/crates/ein-server/Cargo.toml +++ b/crates/ein-server/Cargo.toml @@ -11,6 +11,10 @@ homepage.workspace = true name = "ein_server" path = "src/lib.rs" +[[bin]] +name = "ein-server" +path = "src/main.rs" + [dependencies] anyhow = { workspace = true } diff --git a/crates/ein/src/bin/ein_server.rs b/crates/ein-server/src/main.rs similarity index 100% rename from crates/ein/src/bin/ein_server.rs rename to crates/ein-server/src/main.rs diff --git a/crates/ein-tui/Cargo.toml b/crates/ein-tui/Cargo.toml index 9e8565e..4d0e24c 100644 --- a/crates/ein-tui/Cargo.toml +++ b/crates/ein-tui/Cargo.toml @@ -11,6 +11,10 @@ homepage.workspace = true name = "ein_tui" path = "src/lib.rs" +[[bin]] +name = "ein" +path = "src/main.rs" + [dependencies] anyhow = { workspace = true } @@ -30,3 +34,6 @@ tonic = { workspace = true } tracing = { workspace = true } tracing-appender = "0.2" tracing-subscriber = { workspace = true } +reqwest = { version = "0.13.3", default-features = false, features = ["rustls"] } +flate2 = "1.1.9" +tar = "0.4.45" diff --git a/crates/ein-tui/src/app.rs b/crates/ein-tui/src/app.rs index 34b4c98..f744aea 100644 --- a/crates/ein-tui/src/app.rs +++ b/crates/ein-tui/src/app.rs @@ -30,6 +30,8 @@ pub(crate) enum AppEvent { PluginStatusLoaded(Vec), /// A plugin install RPC completed; carries success flag and a status message. PluginInstallResult { success: bool, message: String }, + /// Background uninstall task finished; carry the step log and outcome. + UninstallComplete { success: bool, steps: Vec }, } /// Whether the TUI currently has a live server connection. @@ -198,6 +200,8 @@ pub(crate) enum Modal { SessionPicker(SessionPickerState), /// CWD access prompt, shown after choosing "New Session". CwdPrompt(CwdState), + /// Uninstall confirmation / progress / result, opened via `/uninstall`. + UninstallConfirm(UninstallModalState), } // --------------------------------------------------------------------------- @@ -229,6 +233,21 @@ pub(crate) struct SessionPickerState { pub(crate) session_tx: oneshot::Sender, } +// --------------------------------------------------------------------------- +// Uninstall modal state +// --------------------------------------------------------------------------- + +pub(crate) enum UninstallPhase { + Confirm, + Running, + Done { success: bool }, +} + +pub(crate) struct UninstallModalState { + pub(crate) phase: UninstallPhase, + pub(crate) log: Vec, +} + /// State for the CWD access modal, shown only when "New Session" is chosen. pub(crate) struct CwdState { pub(crate) cwd: String, diff --git a/crates/ein-tui/src/bootstrap.rs b/crates/ein-tui/src/bootstrap.rs new file mode 100644 index 0000000..005e33d --- /dev/null +++ b/crates/ein-tui/src/bootstrap.rs @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mason Stallmo + +//! Bootstrap logic: downloads `ein-server` on first run and registers it as a +//! system service (macOS LaunchAgent or Linux systemd user service). + +// These items are only called from the #[cfg(not(debug_assertions))] block in +// lib.rs, so they appear unused in debug builds. That's intentional. +#![cfg_attr(debug_assertions, allow(dead_code))] + +use anyhow::{Context, Result}; +use flate2::read::GzDecoder; +use std::{ + io, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; +use tar::Archive; +use tokio::{fs, process::Command, task}; + +const GITHUB_REPO: &str = "mstallmo/ein"; + +/// Path where `ein` installs the server binary: `~/.ein/bin/ein-server`. +pub fn server_bin_path() -> PathBuf { + dirs::home_dir() + .expect("home directory not found") + .join(".ein") + .join("bin") + .join("ein-server") +} + +/// Compile-time target triple used to select the right GitHub release asset. +pub fn target_triple() -> &'static str { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return "aarch64-apple-darwin"; + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return "x86_64-apple-darwin"; + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return "aarch64-unknown-linux-gnu"; + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return "x86_64-unknown-linux-gnu"; + #[allow(unreachable_code)] + "" +} + +/// Downloads the `ein-server` binary for the current platform from GitHub +/// releases and writes it to `~/.ein/bin/ein-server` with executable permissions. +pub async fn download_server(version: &str) -> Result<()> { + let ver = version.trim_start_matches('v'); + let tag = format!("v{ver}"); + let triple = target_triple(); + // cargo-dist names archives as "{package}-{triple}.tar.gz" (no version in filename). + let archive_name = format!("ein-server-{triple}.tar.gz"); + let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); + + let dest = server_bin_path(); + fs::create_dir_all(dest.parent().unwrap()) + .await + .context("failed to create ~/.ein/bin")?; + + println!("Downloading {url}..."); + + let response = reqwest::get(&url) + .await + .with_context(|| format!("failed to fetch {url}"))?; + + if !response.status().is_success() { + anyhow::bail!("download failed: HTTP {}", response.status()); + } + + let bytes = response + .bytes() + .await + .context("failed to read response body")?; + + let dest_clone = dest.clone(); + task::spawn_blocking(move || extract_server(&bytes, &dest_clone)) + .await + .context("extraction task panicked")??; + + // Make the binary executable. + let mut perms = fs::metadata(&dest).await?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&dest, perms).await?; + + println!("ein-server installed to {}", dest.display()); + Ok(()) +} + +/// Extracts the `ein-server` binary from a tar.gz archive into `dest`. +fn extract_server(bytes: &[u8], dest: &Path) -> Result<()> { + let gz = GzDecoder::new(io::Cursor::new(bytes)); + let mut archive = Archive::new(gz); + + for entry in archive + .entries() + .context("failed to read archive entries")? + { + let mut entry = entry.context("corrupt archive entry")?; + let entry_path = entry.path().context("entry has no path")?; + + // The archive contains exactly one file: the `ein-server` binary. + // Accept it regardless of any leading directory component. + let file_name = entry_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + if file_name == "ein-server" { + let mut file = std::fs::File::create(dest) + .with_context(|| format!("failed to create {}", dest.display()))?; + io::copy(&mut entry, &mut file).context("failed to write ein-server")?; + return Ok(()); + } + } + + anyhow::bail!("ein-server binary not found in archive") +} + +// --------------------------------------------------------------------------- +// Service registration +// --------------------------------------------------------------------------- + +/// Ensures `ein-server` is registered as a system service. +/// +/// On macOS, installs a LaunchAgent plist and loads it. +/// On Linux, writes a systemd user unit and enables it. +/// On other platforms, does nothing (the TUI's retry loop handles reconnects). +pub async fn ensure_service_installed() -> Result<()> { + #[cfg(target_os = "macos")] + return ensure_launchagent_installed().await; + + #[cfg(target_os = "linux")] + return ensure_systemd_installed().await; + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + Ok(()) +} + +// --------------------------------------------------------------------------- +// Uninstall +// --------------------------------------------------------------------------- + +/// Stops and removes the `ein-server` service and binary installed by +/// [`ensure_service_installed`] and [`download_server`]. +/// +/// Returns a list of completed step descriptions for display in the TUI. +/// User config and session data in `~/.ein/` are left intact. +pub async fn uninstall() -> Result> { + let mut steps: Vec = Vec::new(); + #[cfg(target_os = "macos")] + uninstall_launchagent(&mut steps).await?; + #[cfg(target_os = "linux")] + uninstall_systemd(&mut steps).await?; + remove_server_binary(&mut steps).await?; + Ok(steps) +} + +async fn remove_server_binary(steps: &mut Vec) -> Result<()> { + let path = server_bin_path(); + if path.exists() { + fs::remove_file(&path) + .await + .with_context(|| format!("failed to remove {}", path.display()))?; + steps.push(format!("Removed {}", path.display())); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +async fn uninstall_launchagent(steps: &mut Vec) -> Result<()> { + let plist = launchagent_plist_path(); + // Ignore errors — the service may already be stopped/unloaded. + let _ = Command::new("launchctl") + .args(["unload", plist.to_str().unwrap_or("")]) + .output() + .await; + if plist.exists() { + fs::remove_file(&plist) + .await + .with_context(|| format!("failed to remove {}", plist.display()))?; + steps.push(format!("Removed LaunchAgent ({})", LAUNCH_AGENT_LABEL)); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +async fn uninstall_systemd(steps: &mut Vec) -> Result<()> { + let unit = systemd_unit_path(); + let _ = Command::new("systemctl") + .args(["--user", "stop", SYSTEMD_SERVICE_NAME]) + .output() + .await; + let _ = Command::new("systemctl") + .args(["--user", "disable", SYSTEMD_SERVICE_NAME]) + .output() + .await; + if unit.exists() { + fs::remove_file(&unit) + .await + .with_context(|| format!("failed to remove {}", unit.display()))?; + steps.push(format!("Removed systemd user service ({})", SYSTEMD_SERVICE_NAME)); + } + let _ = Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .output() + .await; + Ok(()) +} + +// --------------------------------------------------------------------------- +// macOS LaunchAgent +// --------------------------------------------------------------------------- + +#[cfg(target_os = "macos")] +const LAUNCH_AGENT_LABEL: &str = "com.ein.server"; + +#[cfg(target_os = "macos")] +fn launchagent_plist_path() -> PathBuf { + dirs::home_dir() + .expect("home directory not found") + .join("Library") + .join("LaunchAgents") + .join(format!("{LAUNCH_AGENT_LABEL}.plist")) +} + +#[cfg(target_os = "macos")] +async fn ensure_launchagent_installed() -> Result<()> { + // Check if already loaded. + let status = Command::new("launchctl") + .args(["list", LAUNCH_AGENT_LABEL]) + .output() + .await + .context("launchctl not found")?; + + if status.status.success() { + return Ok(()); // Already running. + } + + let plist_path = launchagent_plist_path(); + let bin = server_bin_path(); + let log = dirs::home_dir() + .expect("home directory not found") + .join(".ein") + .join("server.log"); + + let plist = format!( + r#" + + + + Label + {LAUNCH_AGENT_LABEL} + ProgramArguments + + {bin} + + RunAtLoad + + KeepAlive + + StandardOutPath + {log} + StandardErrorPath + {log} + + +"#, + LAUNCH_AGENT_LABEL = LAUNCH_AGENT_LABEL, + bin = bin.display(), + log = log.display(), + ); + + fs::create_dir_all(plist_path.parent().unwrap()) + .await + .context("failed to create LaunchAgents directory")?; + fs::write(&plist_path, plist) + .await + .context("failed to write plist")?; + + let output = Command::new("launchctl") + .args(["load", plist_path.to_str().unwrap()]) + .output() + .await + .context("failed to run launchctl load")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("launchctl load failed: {stderr}"); + } + + println!("ein-server registered as LaunchAgent ({LAUNCH_AGENT_LABEL})"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Linux systemd user service +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +const SYSTEMD_SERVICE_NAME: &str = "ein-server"; + +#[cfg(target_os = "linux")] +fn systemd_unit_path() -> PathBuf { + dirs::home_dir() + .expect("home directory not found") + .join(".config") + .join("systemd") + .join("user") + .join(format!("{SYSTEMD_SERVICE_NAME}.service")) +} + +#[cfg(target_os = "linux")] +async fn ensure_systemd_installed() -> Result<()> { + // Check if already enabled. + let status = Command::new("systemctl") + .args(["--user", "is-enabled", SYSTEMD_SERVICE_NAME]) + .output() + .await + .context("systemctl not found")?; + + if status.status.success() { + return Ok(()); // Already enabled. + } + + let unit_path = systemd_unit_path(); + let bin = server_bin_path(); + + let unit = format!( + "[Unit]\nDescription=Ein server\n\n[Service]\nExecStart={bin}\nRestart=always\n\n[Install]\nWantedBy=default.target\n", + bin = bin.display(), + ); + + fs::create_dir_all(unit_path.parent().unwrap()) + .await + .context("failed to create systemd user directory")?; + fs::write(&unit_path, unit) + .await + .context("failed to write systemd unit")?; + + let reload = Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .output() + .await + .context("failed to run systemctl daemon-reload")?; + + if !reload.status.success() { + let stderr = String::from_utf8_lossy(&reload.stderr); + anyhow::bail!("systemctl daemon-reload failed: {stderr}"); + } + + let enable = Command::new("systemctl") + .args(["--user", "enable", "--now", SYSTEMD_SERVICE_NAME]) + .output() + .await + .context("failed to run systemctl enable")?; + + if !enable.status.success() { + let stderr = String::from_utf8_lossy(&enable.stderr); + anyhow::bail!("systemctl enable failed: {stderr}"); + } + + println!("ein-server registered as systemd user service ({SYSTEMD_SERVICE_NAME})"); + Ok(()) +} diff --git a/crates/ein-tui/src/connection.rs b/crates/ein-tui/src/connection.rs index ac92496..269e2cd 100644 --- a/crates/ein-tui/src/connection.rs +++ b/crates/ein-tui/src/connection.rs @@ -280,10 +280,16 @@ mod tests { let mut plugin_configs = HashMap::new(); plugin_configs.insert( "ein_openrouter".to_string(), - PluginConfig { params, ..Default::default() }, + PluginConfig { + params, + ..Default::default() + }, ); - let cfg = ClientConfig { plugin_configs, ..Default::default() }; + let cfg = ClientConfig { + plugin_configs, + ..Default::default() + }; let proto = to_proto_session_config(&cfg, "id".to_string()); let pc = &proto.plugin_configs["ein_openrouter"]; @@ -304,7 +310,10 @@ mod tests { }, ); - let cfg = ClientConfig { plugin_configs, ..Default::default() }; + let cfg = ClientConfig { + plugin_configs, + ..Default::default() + }; let proto = to_proto_session_config(&cfg, "id".to_string()); let pc = &proto.plugin_configs["Bash"]; @@ -319,7 +328,10 @@ mod tests { plugin_configs.insert("Bash".to_string(), PluginConfig::default()); plugin_configs.insert("Read".to_string(), PluginConfig::default()); - let cfg = ClientConfig { plugin_configs, ..Default::default() }; + let cfg = ClientConfig { + plugin_configs, + ..Default::default() + }; let proto = to_proto_session_config(&cfg, "id".to_string()); assert_eq!(proto.plugin_configs.len(), 3); diff --git a/crates/ein-tui/src/input.rs b/crates/ein-tui/src/input.rs index 666f73d..ce8eb48 100644 --- a/crates/ein-tui/src/input.rs +++ b/crates/ein-tui/src/input.rs @@ -6,7 +6,8 @@ use ein_proto::ein::{UserInput, agent_event::Event as ServerEvent, user_input}; use tracing::{debug, info, warn}; use crate::app::{ - App, CwdState, DisplayMessage, Modal, SessionPickerState, SetupWizardState, WizardStep, + App, CwdState, DisplayMessage, Modal, SessionPickerState, SetupWizardState, UninstallModalState, + UninstallPhase, WizardStep, }; use crate::connection::to_proto_session_config; @@ -56,6 +57,10 @@ pub(crate) const COMMANDS: &[CommandDef] = &[ name: "/setup", description: "Run the first-time setup wizard", }, + CommandDef { + name: "/uninstall", + description: "Stop and remove the ein-server service and binary", + }, ]; /// Recomputes `autocomplete_matches` and `autocomplete_active` based on the @@ -102,6 +107,8 @@ pub(crate) enum KeyAction { OpenSetupWizard, /// Setup wizard saved config; trigger an immediate reconnect. SetupComplete, + /// User confirmed uninstall; spawn the background removal task. + RunUninstall, } // --------------------------------------------------------------------------- @@ -124,6 +131,7 @@ pub(crate) async fn handle_key_event(app: &mut App, key: KeyEvent) -> KeyAction Some(Modal::PluginManager(_)) => handle_plugin_modal_key(app, key), Some(Modal::SessionPicker(_)) => handle_session_picker_key(app, key).await, Some(Modal::CwdPrompt(_)) => handle_cwd_modal_key(app, key), + Some(Modal::UninstallConfirm(_)) => handle_uninstall_modal_key(app, key), None => handle_normal_key(app, key).await, } } @@ -414,6 +422,39 @@ fn handle_cwd_modal_key(app: &mut App, key: KeyEvent) -> KeyAction { KeyAction::Continue } +fn handle_uninstall_modal_key(app: &mut App, key: KeyEvent) -> KeyAction { + let phase = match &app.modal { + Some(Modal::UninstallConfirm(s)) => match s.phase { + UninstallPhase::Confirm => 0, + UninstallPhase::Running => 1, + UninstallPhase::Done { .. } => 2, + }, + _ => return KeyAction::Continue, + }; + + match phase { + 0 => match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some(Modal::UninstallConfirm(s)) = &mut app.modal { + s.phase = UninstallPhase::Running; + } + KeyAction::RunUninstall + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.modal = None; + KeyAction::Continue + } + _ => KeyAction::Continue, + }, + 1 => KeyAction::Continue, // block input while running + _ => { + // Done phase — any key closes the modal and returns to main screen. + app.modal = None; + KeyAction::Continue + } + } +} + async fn handle_normal_key(app: &mut App, key: KeyEvent) -> KeyAction { match key.code { KeyCode::Enter => { @@ -481,6 +522,13 @@ async fn handle_normal_key(app: &mut App, key: KeyEvent) -> KeyAction { "/plugins" => return KeyAction::OpenPluginModal, "/sessions" => return KeyAction::OpenSessionPicker, "/setup" => return KeyAction::OpenSetupWizard, + "/uninstall" => { + app.modal = Some(Modal::UninstallConfirm(UninstallModalState { + phase: UninstallPhase::Confirm, + log: vec![], + })); + return KeyAction::Continue; + } _ => { // Reject unrecognized slash commands — display a local error, do not send to server. if text.starts_with('/') { @@ -913,7 +961,9 @@ mod key_events { let action = handle_key_event(&mut app, key(KeyCode::Enter)).await; assert!(matches!(action, KeyAction::Continue)); assert!( - app.messages.iter().any(|m| matches!(m, DisplayMessage::Error(_))), + app.messages + .iter() + .any(|m| matches!(m, DisplayMessage::Error(_))), "unknown slash command must add an Error message" ); } diff --git a/crates/ein-tui/src/lib.rs b/crates/ein-tui/src/lib.rs index a6e1f2d..29f82bc 100644 --- a/crates/ein-tui/src/lib.rs +++ b/crates/ein-tui/src/lib.rs @@ -7,6 +7,7 @@ //! meta-package binary can share the same entry-point without duplicating code. mod app; +mod bootstrap; mod config; mod connection; mod input; @@ -14,7 +15,7 @@ mod render; use crate::app::{ App, AppEvent, ConnectionStatus, CwdState, DisplayMessage, Modal, PluginModalState, - SessionPickerState, SetupWizardState, + SessionPickerState, SetupWizardState, UninstallPhase, }; use crate::config::load_or_create_config; use crate::connection::{ @@ -97,6 +98,18 @@ pub async fn run(args: Args) -> anyhow::Result<()> { info!(server_addr = %args.server_addr, "ein-tui starting"); + // In release builds: download ein-server if absent, then register it as a + // system service. Runs before raw mode so stdout is visible for progress. + #[cfg(not(debug_assertions))] + { + let bin = bootstrap::server_bin_path(); + if !bin.exists() { + println!("Downloading ein-server {}...", env!("CARGO_PKG_VERSION")); + bootstrap::download_server(env!("CARGO_PKG_VERSION")).await?; + } + bootstrap::ensure_service_installed().await?; + } + // Load (or create) the client config before opening the gRPC session. let cfg = load_or_create_config()?; let first_run = config::is_first_run(&cfg); @@ -302,6 +315,43 @@ pub async fn run(args: Args) -> anyhow::Result<()> { KeyAction::OpenSetupWizard => { app.modal = Some(Modal::SetupWizard(SetupWizardState::new())); } + KeyAction::RunUninstall => { + let tx = event_tx.clone(); + tokio::spawn(async move { + #[cfg(not(debug_assertions))] + { + match bootstrap::uninstall().await { + Ok(steps) => { + let _ = tx + .send(AppEvent::UninstallComplete { + success: true, + steps, + }) + .await; + } + Err(e) => { + let _ = tx + .send(AppEvent::UninstallComplete { + success: false, + steps: vec![format!("Error: {e}")], + }) + .await; + } + } + } + #[cfg(debug_assertions)] + { + let _ = tx + .send(AppEvent::UninstallComplete { + success: true, + steps: vec![ + "(debug build — service removal skipped)".to_string() + ], + }) + .await; + } + }); + } KeyAction::SetupComplete => { app.prompt_tx = None; app.connection_status = ConnectionStatus::Connecting; @@ -417,6 +467,12 @@ pub async fn run(args: Args) -> anyhow::Result<()> { } } } + AppEvent::UninstallComplete { success, steps } => { + if let Some(Modal::UninstallConfirm(s)) = &mut app.modal { + s.phase = UninstallPhase::Done { success }; + s.log = steps; + } + } } } } diff --git a/crates/ein/src/bin/ein_tui.rs b/crates/ein-tui/src/main.rs similarity index 100% rename from crates/ein/src/bin/ein_tui.rs rename to crates/ein-tui/src/main.rs diff --git a/crates/ein-tui/src/render.rs b/crates/ein-tui/src/render.rs index 21cac2c..ac88c48 100644 --- a/crates/ein-tui/src/render.rs +++ b/crates/ein-tui/src/render.rs @@ -19,7 +19,7 @@ use tracing::debug; use crate::app::{ App, ConnectionStatus, DisplayMessage, Modal, PROVIDERS, PluginModalState, SessionPickerState, - SetupWizardState, WizardStep, + SetupWizardState, UninstallModalState, UninstallPhase, WizardStep, }; use crate::input::COMMANDS; @@ -322,6 +322,9 @@ pub(crate) fn render(app: &App, frame: &mut Frame) { Modal::CwdPrompt(cwd_state) => { render_cwd_modal(&cwd_state.cwd, frame); } + Modal::UninstallConfirm(state) => { + render_uninstall_modal(state, app.tick, frame); + } } } @@ -755,6 +758,104 @@ fn render_cwd_modal(cwd: &str, frame: &mut Frame) { frame.render_widget(Paragraph::new(lines), inner); } +fn render_uninstall_modal(state: &UninstallModalState, tick: u64, frame: &mut Frame) { + // Height: 2 borders + blank line + content lines + blank + hint + let content_lines = match &state.phase { + UninstallPhase::Confirm => 4u16, + UninstallPhase::Running => 1u16, + UninstallPhase::Done { .. } => state.log.len().max(1) as u16 + 2, + }; + let modal_height = 2 + 1 + content_lines; + let modal_width = (frame.area().width * 7 / 10).max(60).min(frame.area().width); + let area = centered_rect(modal_width, modal_height, frame.area()); + + frame.render_widget(Clear, area); + + let (title, border_color) = match &state.phase { + UninstallPhase::Confirm => (" Uninstall ein-server? ", DISCONNECTED_COLOR), + UninstallPhase::Running => (" Uninstalling… ", MUTED_COLOR), + UninstallPhase::Done { success: true } => (" Uninstalled ", Color::Green), + UninstallPhase::Done { success: false } => (" Uninstall failed ", DISCONNECTED_COLOR), + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + let mut lines = vec![Line::raw("")]; + + match &state.phase { + UninstallPhase::Confirm => { + lines.push(Line::from(Span::styled( + " Stop the service and remove the server binary.", + Style::default().fg(AUTOCOMPLETE_TOP_COLOR), + ))); + lines.push(Line::from(Span::styled( + " Config and sessions in ~/.ein/ will be preserved.", + Style::default().fg(MUTED_COLOR), + ))); + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled( + " [Y]", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" Confirm ", Style::default().fg(MUTED_COLOR)), + Span::styled( + "[N]", + Style::default() + .fg(DISCONNECTED_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" Cancel", Style::default().fg(MUTED_COLOR)), + ])); + } + UninstallPhase::Running => { + lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", SPINNER[tick as usize % SPINNER.len()]), + Style::default().fg(THINKING_COLOR), + ), + Span::styled( + "Uninstalling…", + Style::default() + .fg(MUTED_COLOR) + .add_modifier(Modifier::ITALIC), + ), + ])); + } + UninstallPhase::Done { success } => { + for step in &state.log { + lines.push(Line::from(Span::styled( + format!(" {step}"), + Style::default().fg(if *success { + Color::Green + } else { + DISCONNECTED_COLOR + }), + ))); + } + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + " Press any key to dismiss", + Style::default() + .fg(MUTED_COLOR) + .add_modifier(Modifier::ITALIC), + ))); + } + } + + frame.render_widget(Paragraph::new(lines), inner); +} + /// Renders the session picker modal, overlaying the entire terminal. fn render_session_picker(picker: &SessionPickerState, frame: &mut Frame) { // Row 0 = "New Session" (always); subsequent rows = existing sessions (cap at 8). diff --git a/crates/ein/Cargo.toml b/crates/ein/Cargo.toml deleted file mode 100644 index afe0fa9..0000000 --- a/crates/ein/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "ein" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "AI agent framework — installs both ein-tui and ein-server" -repository.workspace = true -homepage.workspace = true - -[dependencies] -anyhow = { workspace = true } -clap = { version = "4", features = ["derive"] } -ein-server = { path = "../ein-server" } -ein-tui = { path = "../ein-tui" } -tokio = { workspace = true } - -[[bin]] -name = "ein-server" -path = "src/bin/ein_server.rs" - -[[bin]] -name = "ein-tui" -path = "src/bin/ein_tui.rs" diff --git a/packages/ein_ollama/src/lib.rs b/packages/ein_ollama/src/lib.rs index 733327c..cac3c6f 100644 --- a/packages/ein_ollama/src/lib.rs +++ b/packages/ein_ollama/src/lib.rs @@ -70,13 +70,11 @@ fn map_http_error(status: u16, body: &str, model: &str) -> Option )) } 402 => { - let msg = - extract_api_error(body).unwrap_or_else(|| "Payment required".to_owned()); + let msg = extract_api_error(body).unwrap_or_else(|| "Payment required".to_owned()); Some(anyhow!("{msg}")) } 404 => { - let msg = - extract_api_error(body).unwrap_or_else(|| "Model not found".to_owned()); + let msg = extract_api_error(body).unwrap_or_else(|| "Model not found".to_owned()); Some(anyhow!( "{msg}\n\n\ The model may not be downloaded yet. Run:\n\ @@ -186,10 +184,7 @@ mod tests { #[test] fn extract_api_error_present() { let body = r#"{"error": {"message": "model not loaded", "type": "not_found"}}"#; - assert_eq!( - extract_api_error(body).as_deref(), - Some("model not loaded") - ); + assert_eq!(extract_api_error(body).as_deref(), Some("model not loaded")); } #[test] @@ -282,7 +277,10 @@ mod tests { fn map_http_error_404_suggests_ollama_pull() { let err = map_http_error(404, "{}", "mistral").unwrap(); let msg = err.to_string(); - assert!(msg.contains("ollama pull"), "expected 'ollama pull' in: {msg}"); + assert!( + msg.contains("ollama pull"), + "expected 'ollama pull' in: {msg}" + ); assert!(msg.contains("mistral"), "expected model name in: {msg}"); } diff --git a/packages/ein_openrouter/src/lib.rs b/packages/ein_openrouter/src/lib.rs index 1f67683..7b177d7 100644 --- a/packages/ein_openrouter/src/lib.rs +++ b/packages/ein_openrouter/src/lib.rs @@ -38,15 +38,13 @@ fn map_http_error(status: u16, body: &str) -> Option { )) } 402 => { - let msg = - extract_api_error(body).unwrap_or_else(|| "Insufficient credits".to_owned()); + let msg = extract_api_error(body).unwrap_or_else(|| "Insufficient credits".to_owned()); Some(anyhow!( "{msg}\n\nCheck your account balance at openrouter.ai." )) } 404 => { - let msg = - extract_api_error(body).unwrap_or_else(|| "Resource not found".to_owned()); + let msg = extract_api_error(body).unwrap_or_else(|| "Resource not found".to_owned()); Some(anyhow!("{msg}")) } s if !(200..300).contains(&s) => { @@ -194,7 +192,10 @@ mod tests { fn map_http_error_402_mentions_credits_and_balance() { let err = map_http_error(402, "{}").unwrap(); let msg = err.to_string(); - assert!(msg.contains("openrouter.ai"), "expected openrouter.ai link in: {msg}"); + assert!( + msg.contains("openrouter.ai"), + "expected openrouter.ai link in: {msg}" + ); } #[test] diff --git a/packages/ein_read/src/lib.rs b/packages/ein_read/src/lib.rs index 87090ac..d12c294 100644 --- a/packages/ein_read/src/lib.rs +++ b/packages/ein_read/src/lib.rs @@ -192,7 +192,10 @@ mod tests { #[test] fn read_truncation_header_shows_range_and_total() { // 10 lines, read only first 4 - let content = (1..=10).map(|i| format!("line{i}")).collect::>().join("\n"); + let content = (1..=10) + .map(|i| format!("line{i}")) + .collect::>() + .join("\n"); let f = write_temp(&content); let out = call(f.path().to_str().unwrap(), None, Some(4)).unwrap(); assert!(out.starts_with("Lines 1-4 of 10"), "got: {out}"); @@ -201,7 +204,10 @@ mod tests { #[test] fn read_truncation_header_reflects_offset() { - let content = (1..=10).map(|i| format!("line{i}")).collect::>().join("\n"); + let content = (1..=10) + .map(|i| format!("line{i}")) + .collect::>() + .join("\n"); let f = write_temp(&content); let out = call(f.path().to_str().unwrap(), Some(3), Some(3)).unwrap(); // offset=3, limit=3 → lines 4-6 (1-based), 4 remain → header diff --git a/packages/ein_write/src/lib.rs b/packages/ein_write/src/lib.rs index d00d04b..96c48c8 100644 --- a/packages/ein_write/src/lib.rs +++ b/packages/ein_write/src/lib.rs @@ -134,7 +134,15 @@ mod tests { let dir = TempDir::new().unwrap(); let path = dir.path().join("f.txt"); let result = call(path.to_str().unwrap(), "abc").unwrap(); - assert!(result.content.contains('3'), "expected byte count in: {}", result.content); - assert!(result.content.contains(path.to_str().unwrap()), "expected path in: {}", result.content); + assert!( + result.content.contains('3'), + "expected byte count in: {}", + result.content + ); + assert!( + result.content.contains(path.to_str().unwrap()), + "expected path in: {}", + result.content + ); } }