//! Command parsing and the minimal line console. use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; use include_dir::{Dir, include_dir}; use sausage::{Host, world::World}; use serde_json::json; use std::{ io::Write, path::{Path, PathBuf}, }; static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src"); mod test_runner; #[derive(Parser)] #[command(version)] struct Cli { #[arg(long, global = true, env = "SAUSAGE_HEADLESS", value_parser = headless)] headless: bool, #[arg(long, global = true)] cache_dir: Option, #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Cmd { Init { #[arg(default_value = "world")] dir: PathBuf, }, Run { #[arg(default_value = "world")] dir: PathBuf, }, /// Run a WASI libtest component in isolated instances. TestRunner { wasm: PathBuf, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, } fn headless(value: &str) -> std::result::Result { match value.to_ascii_lowercase().as_str() { "1" | "true" => Ok(true), "" | "0" | "false" => Ok(false), _ => Err("expected 1, true, 0, false, or an empty value".into()), } } // Readline can still wait on its detached thread when the host exits. struct TerminalMode(Option); impl Drop for TerminalMode { fn drop(&mut self) { if let Some(mode) = &self.0 { let _ = nix::sys::termios::tcsetattr( std::io::stdin(), nix::sys::termios::SetArg::TCSANOW, mode, ); } } } fn setting(var: &str, question: &str, default: Option<&str>) -> Result { if let Ok(v) = std::env::var(var) && !v.is_empty() { return Ok(v); } print!("{question}: "); std::io::stdout().flush()?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; if !line.trim().is_empty() { return Ok(line.trim().into()); } match default { Some(v) => Ok(v.into()), None => bail!("{question} is required"), } } /// Creates a configured world and builds its embedded agent through the standalone builder. async fn init(dir: &Path) -> Result<()> { let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?; if api != "openai-responses" && api != "anthropic-messages" { bail!("unknown API shape {api:?}"); } let base_url = setting("SAUSAGE_BASE_URL", "Base URL", None)?; let model = setting("SAUSAGE_MODEL", "Model", None)?; let token = setting("SAUSAGE_API_TOKEN", "API token", None)?; if dir.exists() { bail!("world already exists: {}", dir.display()); } let parent = dir .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or(Path::new(".")); std::fs::create_dir_all(parent)?; let staging = tempfile::Builder::new() .prefix(".sausage-init-") .tempdir_in(parent)?; let world = World::create(&staging.path().join("world"))?; std::fs::write( world.root.join("config/agent.json"), serde_json::to_vec_pretty( &json!({"api":api,"base_url":base_url.trim_end_matches('/'),"model":model,"token":token}), )?, )?; let builder = sausage::cargo::Cargo::new(world.clone())?; let source = world.plugin_dir("agent").join("source"); std::fs::create_dir_all(source.join("src"))?; AGENT.extract(source.join("src"))?; std::fs::write( source.join("Cargo.toml"), include_str!("../../agent/Cargo.toml"), )?; std::fs::write( source.join("Cargo.lock"), include_bytes!("../../Cargo.lock"), )?; println!("The first agent build takes a few minutes."); let output = builder.build(&source).await?; std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?; std::fs::rename(&world.root, dir)?; println!("Start the world with: sausage run {}", dir.display()); Ok(()) } #[tokio::main] async fn main() -> Result { let cli = Cli::parse(); let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok()); let command = match cli.command { Some(command) => command, None => { let dir = PathBuf::from("world"); if !dir.join("config/boot.json").is_file() { init(&dir).await?; } Cmd::Run { dir } } }; match command { Cmd::TestRunner { wasm, args } => { if !test_runner::run(&wasm, args).await? { return Ok(std::process::ExitCode::FAILURE); } } Cmd::Init { dir } => init(&dir).await?, Cmd::Run { dir } => { let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; let start = async { match cli.cache_dir { Some(cache) => Host::start_with_cache(&dir, &cache).await, None => Host::start(&dir).await, } }; let host = tokio::select! { result=start=>result?, _=term.recv()=>return Ok(std::process::ExitCode::SUCCESS), _=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS), }; let mut notifications = host.subscribe(); let notification_task = cli.headless.then(|| { tokio::spawn(async move { loop { match notifications.recv().await { Ok(notification) => println!("{notification}"), Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } }) }); let console = sausage::console::run_input(host.clone()); let mut console_result = Ok(()); let exit = tokio::select! { _=host.exit_requested()=>true, _=term.recv()=>false, _=interrupt.recv()=>false, result = console, if !cli.headless => { console_result=result; false }, }; let exit = exit || host.restart_requested(); host.stop().await; if let Some(task) = notification_task { task.abort(); } if exit { return Ok(std::process::ExitCode::from(75)); } console_result?; } } Ok(std::process::ExitCode::SUCCESS) }