thal 0.0.1

Reactive semantic runtime — molecules, reactions, and effect actors for building LLM-backed applications as dataflow programs.
Documentation
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use thal::llm::{AnthropicProvider, OpenAiCompatProvider};
use thal::Reactor;

#[tokio::main]
async fn main() -> ExitCode {
    // Default to warn — info-level events from the runtime (provider
    // registrations, etc.) are noise during a chat session. Bring them back
    // with `RUST_LOG=thal=info` for debugging.
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "thal=warn".into()),
        )
        .with_target(false)
        .compact()
        .init();

    let args: Vec<String> = std::env::args().skip(1).collect();
    match args.first().map(String::as_str) {
        Some("setup") => thal::setup::run(args.get(1).map(String::as_str)).await,
        Some("login") => match args.get(1) {
            Some(provider) => login_command(provider),
            None => {
                eprintln!("usage: thal login <provider>");
                ExitCode::from(2)
            }
        },
        Some(path) => run_program(path).await,
        None => {
            eprintln!("usage:");
            eprintln!("  thal <file.thal>      run a thal program");
            eprintln!("  thal setup [<name>]   interactive provider setup wizard");
            eprintln!("  thal login <provider> save a provider token to ~/.config/thal");
            ExitCode::from(2)
        }
    }
}

async fn run_program(path: &str) -> ExitCode {
    print_banner();

    let program = match thal::load(path) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("error: {e}");
            return ExitCode::from(1);
        }
    };

    let reactor = Reactor::new(program);
    register_providers_from_env(&reactor);

    if let Err(e) = reactor.run().await {
        eprintln!("runtime error: {e}");
        return ExitCode::from(1);
    }
    ExitCode::SUCCESS
}

fn print_banner() {
    use owo_colors::OwoColorize;
    // ANSI Shadow figlet rendered with box-drawing characters. Cyan body,
    // dimmed tagline, neutral hint.
    let lines = [
        "  ████████╗██╗  ██╗ █████╗ ██╗     ",
        "  ╚══██╔══╝██║  ██║██╔══██╗██║     ",
        "     ██║   ███████║███████║██║     ",
        "     ██║   ██╔══██║██╔══██║██║     ",
        "     ██║   ██║  ██║██║  ██║███████╗",
        "     ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝",
    ];
    eprintln!();
    for line in lines {
        eprintln!("{}", line.cyan().bold());
    }
    eprintln!(
        "       {}",
        "reactive semantic runtime".dimmed()
    );
    eprintln!(
        "       {}\n",
        "/help · /exit · Ctrl+D to quit".dimmed()
    );
}

fn register_providers_from_env(reactor: &Reactor) {
    let providers = reactor.llm_providers();

    if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
        providers.register(Arc::new(AnthropicProvider::new(key)));
        tracing::info!("registered Anthropic provider from ANTHROPIC_API_KEY");
    }
    if let Ok(key) = std::env::var("OPENAI_API_KEY") {
        providers.register(Arc::new(OpenAiCompatProvider::openai(key)));
        tracing::info!("registered OpenAI provider from OPENAI_API_KEY");
    }
    if let Ok(key) = std::env::var("OPENROUTER_API_KEY") {
        providers.register(Arc::new(OpenAiCompatProvider::openrouter(key)));
        tracing::info!("registered OpenRouter provider from OPENROUTER_API_KEY");
    }

    // Token-file-backed providers (Codex, Hermes, anything with a CLI that
    // produces a token file) are configured via the `LlmProvider` molecule
    // in `startup { ... }`, not env vars. See plan 20.
}

fn login_command(provider: &str) -> ExitCode {
    let dir = match config_dir() {
        Some(d) => d,
        None => {
            eprintln!("error: cannot determine config directory (set XDG_CONFIG_HOME or HOME)");
            return ExitCode::from(1);
        }
    };
    if let Err(e) = std::fs::create_dir_all(&dir) {
        eprintln!("error: create {}: {e}", dir.display());
        return ExitCode::from(1);
    }

    let path = dir.join(format!("{provider}.token"));

    let stderr = io::stderr();
    let mut stderr = stderr.lock();
    let _ = write!(stderr, "paste your {provider} token: ");
    let _ = stderr.flush();

    let stdin = io::stdin();
    let mut line = String::new();
    if let Err(e) = stdin.lock().read_line(&mut line) {
        eprintln!("error: read stdin: {e}");
        return ExitCode::from(1);
    }
    let token = line.trim();
    if token.is_empty() {
        eprintln!("error: empty token");
        return ExitCode::from(1);
    }

    if let Err(e) = std::fs::write(&path, token) {
        eprintln!("error: write {}: {e}", path.display());
        return ExitCode::from(1);
    }

    // Best-effort: tighten file mode on unix so other users can't read it.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
    }

    eprintln!("saved token to {}", path.display());
    eprintln!(
        "tip: register the provider by exporting:\n  {NAME}_TOKEN_FILE={path}\n  {NAME}_BASE_URL=<api base url>",
        NAME = provider.to_uppercase(),
        path = path.display()
    );
    ExitCode::SUCCESS
}

fn config_dir() -> Option<PathBuf> {
    if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
        return Some(PathBuf::from(d).join("thal"));
    }
    if let Some(home) = std::env::var_os("HOME") {
        return Some(PathBuf::from(home).join(".config").join("thal"));
    }
    None
}