//! Daemon configuration loaded from `~/.spacesh/config.toml`. //! //! The file is optional; every field has a sane fallback so a missing or //! partial config never breaks startup. use std::path::Path; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct TerminalConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub font_family: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub font_size: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct AppearanceConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub theme: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub accent: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Config { /// Shell launched for plain (no-command) panels. When unset, the daemon /// auto-detects the user's login shell. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_shell: Option, #[serde(default)] pub terminal: TerminalConfig, #[serde(default)] pub appearance: AppearanceConfig, } impl Config { /// Resolve to a client-facing view, applying defaults and the shell resolver. pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView { spacesh_proto::config_view::ConfigView { default_shell: self.resolved_shell(), font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()), font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20), theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()), accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()), } } /// Shell for a plain panel using THIS in-memory config /// (env -> config -> passwd -> $SHELL -> /bin/sh). pub fn resolved_shell(&self) -> String { if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } } if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } } if let Some(s) = login_shell() { return s; } if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } } "/bin/sh".into() } /// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults. pub fn load() -> Self { let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() }; Self::from_path(&dir.join("config.toml")) } pub fn from_path(path: &Path) -> Self { match std::fs::read_to_string(path) { Ok(s) => toml::from_str(&s).unwrap_or_default(), Err(_) => Self::default(), } } /// Persist to `~/.spacesh/config.toml`. pub fn save(&self) -> std::io::Result<()> { let dir = crate::lifecycle::spacesh_dir() .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; self.save_to(&dir.join("config.toml")) } /// Persist to an arbitrary path. Creates the parent directory if needed. pub fn save_to(&self, path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let s = toml::to_string_pretty(self) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; std::fs::write(path, s) } } /// Resolve the shell to spawn for a plain panel. /// /// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell /// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under /// launchd, where `$SHELL` is typically absent (so a bash fallback would win). // retained for the env-override test and potential startup use #[allow(dead_code)] pub fn default_shell() -> String { if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } } if let Some(s) = Config::load().default_shell { if !s.is_empty() { return s; } } if let Some(s) = login_shell() { return s; } if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } } "/bin/sh".into() } /// The user's full PATH for spawning panels: their login-shell PATH (which sources /// `.zprofile`/`.zshrc`), merged with the daemon's current PATH and common install /// dirs. Cached for the daemon's lifetime. /// /// Why: when the GUI launches the daemon (Finder/launchd), the inherited PATH is /// minimal (`/usr/bin:/bin:…`), so agents like `claude`, `codex`, `gemini` — installed /// in `~/.local/bin`, npm-global, or Homebrew — aren't found and the panel exits /// immediately with "Process exited". A bare `/bin/zsh` still works, which is why /// shells launched fine but agents didn't. pub fn enriched_path() -> String { use std::collections::HashSet; use std::sync::OnceLock; static CACHE: OnceLock = OnceLock::new(); CACHE .get_or_init(|| { let mut dirs: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); let mut merge = |src: &str| { for d in src.split(':') { if !d.is_empty() && seen.insert(d.to_string()) { dirs.push(d.to_string()); } } }; if let Some(p) = login_shell_path() { merge(&p); } if let Ok(p) = std::env::var("PATH") { merge(&p); } merge(&fallback_path_dirs()); dirs.join(":") }) .clone() } /// Whether `cmd` is an executable resolvable on the spawn PATH (or an existing path /// if it contains a slash). Used to only offer agents the user actually has installed. pub fn is_installed(cmd: &str) -> bool { use std::os::unix::fs::PermissionsExt; if cmd.is_empty() { return false; } let is_exec = |p: &std::path::Path| { p.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false) }; if cmd.contains('/') { return is_exec(std::path::Path::new(cmd)); } enriched_path().split(':').any(|dir| !dir.is_empty() && is_exec(&std::path::Path::new(dir).join(cmd))) } /// Common locations user-installed CLIs land in, as a colon-joined fallback. fn fallback_path_dirs() -> String { let mut v = vec![ "/opt/homebrew/bin".to_string(), "/usr/local/bin".to_string(), "/usr/bin".to_string(), "/bin".to_string(), "/usr/sbin".to_string(), "/sbin".to_string(), ]; if let Ok(home) = std::env::var("HOME") { if !home.is_empty() { for d in [".local/bin", ".npm-global/bin", ".cargo/bin", ".bun/bin", ".deno/bin", ".volta/bin", "go/bin"] { v.push(format!("{home}/{d}")); } } } v.join(":") } /// Capture PATH from the user's login+interactive shell so rc-file PATH edits apply. /// Parses `env` output (the exported PATH is colon-joined regardless of shell, so this /// works for fish too, where `$PATH` would otherwise print space-separated). #[cfg(unix)] fn login_shell_path() -> Option { let shell = login_shell().or_else(|| std::env::var("SHELL").ok())?; let out = std::process::Command::new(&shell) .args(["-lic", "env"]) .output() .ok()?; String::from_utf8_lossy(&out.stdout) .lines() .find_map(|l| l.strip_prefix("PATH=")) .map(|p| p.trim().to_string()) .filter(|p| !p.is_empty()) } #[cfg(not(unix))] fn login_shell_path() -> Option { None } /// The current user's login shell from the passwd database (`getpwuid`). #[cfg(unix)] fn login_shell() -> Option { use std::ffi::CStr; // SAFETY: getpwuid returns a pointer into a static buffer valid until the // next libc passwd call; we copy the string out immediately on this thread. unsafe { let pw = libc::getpwuid(libc::getuid()); if pw.is_null() { return None; } let shell = (*pw).pw_shell; if shell.is_null() { return None; } let s = CStr::from_ptr(shell).to_str().ok()?; if s.is_empty() { None } else { Some(s.to_string()) } } } #[cfg(not(unix))] fn login_shell() -> Option { None } #[cfg(test)] mod tests { use super::*; #[test] fn missing_file_is_default() { let c = Config::from_path(Path::new("/no/such/spacesh/config.toml")); assert!(c.default_shell.is_none()); } #[test] fn parses_default_shell() { let dir = std::env::temp_dir().join("spacesh-cfg-test"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); std::fs::write(&path, "default_shell = \"/bin/zsh\"\n").unwrap(); let c = Config::from_path(&path); assert_eq!(c.default_shell.as_deref(), Some("/bin/zsh")); let _ = std::fs::remove_file(&path); } #[test] fn env_override_wins() { let _serial = crate::test_support::serial(); std::env::set_var("SPACESH_SHELL", "/tmp/fake-shell"); let s = default_shell(); std::env::remove_var("SPACESH_SHELL"); assert_eq!(s, "/tmp/fake-shell"); } #[test] fn parses_terminal_and_appearance() { let dir = std::env::temp_dir().join(format!("spacesh-cfg-sections-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); std::fs::write(&path, "default_shell = \"/bin/zsh\"\n[terminal]\nfont_family = \"Menlo\"\nfont_size = 15\n[appearance]\ntheme = \"light\"\naccent = \"teal\"\n").unwrap(); let c = Config::from_path(&path); assert_eq!(c.terminal.font_family.as_deref(), Some("Menlo")); assert_eq!(c.terminal.font_size, Some(15)); assert_eq!(c.appearance.theme.as_deref(), Some("light")); assert_eq!(c.appearance.accent.as_deref(), Some("teal")); let _ = std::fs::remove_file(&path); } #[test] fn missing_sections_default() { let c = Config::from_path(Path::new("/no/such/cfg.toml")); assert!(c.terminal.font_family.is_none()); assert!(c.appearance.theme.is_none()); } #[test] fn to_view_applies_defaults_and_clamp() { let mut c = Config::default(); c.terminal.font_size = Some(99); let v = c.to_view(); assert_eq!(v.font_size, 20); assert_eq!(v.theme, "dark"); assert_eq!(v.accent, "blue"); assert!(!v.font_family.is_empty()); } #[test] fn save_then_reload_round_trips() { let dir = std::env::temp_dir().join(format!("spacesh-cfg-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); let mut c = Config::default(); c.terminal.font_size = Some(14); c.appearance.accent = Some("purple".into()); c.save_to(&path).unwrap(); let back = Config::from_path(&path); assert_eq!(back.terminal.font_size, Some(14)); assert_eq!(back.appearance.accent.as_deref(), Some("purple")); let _ = std::fs::remove_file(&path); } }