//! Application context: configuration, catalog, state, paths, logger and //! shared flags. use crate::catalog::Catalog; use crate::cli::Cli; use crate::config::{self, Config, Settings}; use crate::output::Logger; use crate::state::StateStore; use crate::theme::{self, Theme}; use anyhow::{anyhow, Context, Result}; use std::cell::Cell; use std::io::{IsTerminal, Write}; use std::path::PathBuf; #[derive(Debug)] pub struct App { pub cli: Cli, pub config: Config, pub config_origin: Option, pub paths: Paths, pub catalog: Catalog, pub state: StateStore, pub log: Logger, /// Active output color theme (switchable at runtime with the REPL /// 'theme ' command). theme: Cell<&'static Theme>, } #[derive(Debug, Clone)] pub struct Paths { pub install_dir: PathBuf, pub bin_dir: PathBuf, pub log_dir: PathBuf, pub state_file: PathBuf, pub probe_cache_file: PathBuf, pub config_dir: Option, } impl Paths { pub fn from_settings(settings: &Settings) -> Self { let install_dir = settings .install_dir .as_deref() .map(config::expand_path) .unwrap_or_else(|| data_dir().join("agents")); let log_dir = settings .log_dir .as_deref() .map(config::expand_path) .unwrap_or_else(|| state_dir().join("logs")); let bin_dir = install_dir.join("bin"); let state_file = state_dir().join("state.json"); let probe_cache_file = state_dir().join("probe-cache.json"); let config_dir = config::user_config_dir(); Self { install_dir, bin_dir, log_dir, state_file, probe_cache_file, config_dir, } } /// Create every directory the manager needs. pub fn ensure(&self) -> Result<()> { std::fs::create_dir_all(&self.install_dir) .with_context(|| format!("cannot create {}", self.install_dir.display()))?; std::fs::create_dir_all(&self.bin_dir)?; std::fs::create_dir_all(&self.log_dir)?; Ok(()) } } impl App { pub fn from_cli(cli: Cli) -> Result { let loaded = config::load(&cli)?; let paths = Paths::from_settings(&loaded.config.settings); let log = Logger::new(paths.log_dir.clone(), cli.verbose, cli.quiet, cli.no_color); let state = StateStore::new(paths.state_file.clone()); let catalog = Catalog::new( loaded.config.agents.clone(), loaded.config.aliases.clone(), loaded.config.groups.clone(), ); let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?; Ok(Self { cli, config: loaded.config, config_origin: loaded.origin, paths, catalog, state, log, theme: Cell::new(theme), }) } pub fn dry_run(&self) -> bool { self.cli.dry_run } pub fn json(&self) -> bool { self.cli.json } pub fn yes(&self) -> bool { self.cli.yes } pub fn color(&self) -> bool { !self.cli.no_color && std::io::stdout().is_terminal() } pub fn theme(&self) -> &'static Theme { self.theme.get() } /// Switch the active theme (used by the REPL 'theme ' command). pub fn set_theme(&self, theme: &'static Theme) { self.theme.set(theme); } /// Ask the user for confirmation on stderr. Accepts y/yes/o/oui. pub fn confirm(&self, question: &str) -> Result { if self.cli.yes { return Ok(true); } eprint!("{question} [y/N] "); std::io::stderr().flush().ok(); let mut line = String::new(); std::io::stdin().read_line(&mut line).ok(); let answer = line.trim().to_lowercase(); Ok(matches!(answer.as_str(), "y" | "yes" | "o" | "oui" | "true")) } } /// Resolve the output color theme: the --theme flag wins, then the /// configuration entry, then the default theme. An unknown --theme value is /// an error; an unknown configured value silently falls back to the default. fn resolve_theme(flag: Option<&str>, configured: Option<&str>) -> Result<&'static Theme> { if let Some(name) = flag { return theme::find(name).ok_or_else(|| { anyhow!( "unknown theme '{name}' — available: {}", theme::names().join(", ") ) }); } Ok(configured .and_then(theme::find) .unwrap_or_else(theme::default_theme)) } /// Root directory for agent data (overridable with AGENT_MANAGER_DATA, /// mostly used by tests). pub fn data_dir() -> PathBuf { if let Some(d) = std::env::var_os("AGENT_MANAGER_DATA") { return PathBuf::from(d); } if cfg!(windows) { let base = std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .or_else(|| config::home_dir().map(|h| h.join(".local").join("share"))); return base .map(|b| b.join("agent-manager")) .unwrap_or_else(|| PathBuf::from(".agent-manager")); } if cfg!(target_os = "macos") { return config::home_dir() .map(|h| h.join("Library").join("Application Support").join("agent-manager")) .unwrap_or_else(|| PathBuf::from(".agent-manager")); } let base = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .or_else(|| config::home_dir().map(|h| h.join(".local").join("share"))); base.map(|b| b.join("agent-manager")) .unwrap_or_else(|| PathBuf::from(".agent-manager")) } /// Root directory for runtime state (logs, state file). pub fn state_dir() -> PathBuf { if let Some(d) = std::env::var_os("AGENT_MANAGER_STATE") { return PathBuf::from(d); } if cfg!(target_os = "linux") { let base = std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) .or_else(|| config::home_dir().map(|h| h.join(".local").join("state"))); if let Some(b) = base { return b.join("agent-manager"); } } data_dir() }