Files
sausage/cli/src/main.rs
T
surma a090e1fd57 host: review fixes for cargo.build, the test runner, the console, and main; tests for every PROTOCOL.md rule
cargo.build and the runner (review T22R): builds use the network again;
core.unlock and core.notify in order under the runner; quiet test
output; manifest preparation keeps [workspace], target tables, renamed
and optional entries; cargo.rpc.discover; -32004 with data.output;
retention of 10 builds; light, empty test worlds with an in-memory
journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner
through --config. A compile cache that cannot be created is not fatal.
Activation replaces plugin.wasm without a gap.

Console and main: init ships the workspace Cargo.lock and creates the
world atomically; each console command runs in its own task, so ps,
stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal
mode is restored; signals during boot; chat text is not taken as a
command.

Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests
choose the libtest binary from cargo's JSON output and time out.
2026-10-23 07:47:39 +01:00

200 lines
6.8 KiB
Rust

//! 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<PathBuf>,
#[command(subcommand)]
command: Option<Cmd>,
}
#[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<String>,
},
}
fn headless(value: &str) -> std::result::Result<bool, String> {
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<nix::sys::termios::Termios>);
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<String> {
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<std::process::ExitCode> {
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)
}