a3s 0.8.2

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
use std::path::{Path, PathBuf};
use std::sync::Arc;

use a3s_boot::{BootApplication, BootError};
use a3s_code_core::{Agent, CodeConfig};

use crate::config;

use self::api_gateway::ApiGateway;
use self::options::ServeOptions;
use super::code_web::{CodeWebModule, CodeWebState};
use super::web::{api_only_fallback, find_default_web_dir, serve_static};

mod api_gateway;
mod options;

const API_PREFIX: &str = "/api";
const BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;

pub(crate) fn usage_text() -> String {
    [
        "a3s code serve".to_string(),
        String::new(),
        "usage:".to_string(),
        "  a3s code serve [--host 127.0.0.1] [--port 29653]".to_string(),
        "                 [--workspace <path>] [--web-dir <path>] [--api-only]".to_string(),
        String::new(),
        "Starts the local Boot-backed A3S Code API and serves the Shu Xiao'an web UI.".to_string(),
    ]
    .join("\n")
        + "\n"
}

pub(crate) async fn run(args: &[String]) -> anyhow::Result<()> {
    let options = ServeOptions::parse(args)?;
    if options.help {
        print!("{}", usage_text());
        return Ok(());
    }

    let config_path = ensure_config_path()?;
    let code_config = CodeConfig::from_file(Path::new(&config_path))
        .map_err(|e| anyhow::anyhow!("failed to parse {config_path}: {e}"))?;
    let agent = Arc::new(
        Agent::new(config_path.clone())
            .await
            .map_err(|e| anyhow::anyhow!("failed to load A3S Code from {config_path}: {e}"))?,
    );
    let state = Arc::new(CodeWebState::new(
        agent,
        PathBuf::from(&config_path),
        options.workspace.clone(),
        code_config,
    ));

    let app = BootApplication::builder()
        .global_prefix(API_PREFIX)
        .import(CodeWebModule::new(Arc::clone(&state)))
        .build()
        .map_err(boot_to_anyhow)?;

    app.bootstrap().await.map_err(boot_to_anyhow)?;
    let api_router = ApiGateway::new(app.clone(), BODY_LIMIT_BYTES).router();

    let router = if options.api_only {
        api_router.fallback(api_only_fallback)
    } else {
        let web_dir = options.web_dir.clone().or_else(find_default_web_dir).ok_or_else(|| {
            anyhow::anyhow!("web assets were not found; run `npm --prefix apps/web run build` or pass --web-dir")
        })?;
        if !web_dir.join("index.html").is_file() {
            anyhow::bail!(
                "web assets at {} do not contain index.html; run `npm --prefix apps/web run build`",
                web_dir.display()
            );
        }
        let web_root = Arc::new(web_dir);
        api_router.fallback({
            let web_root = Arc::clone(&web_root);
            move |uri| serve_static(uri, Arc::clone(&web_root))
        })
    };

    let listener = tokio::net::TcpListener::bind(options.addr)
        .await
        .map_err(|e| anyhow::anyhow!("failed to bind {}: {e}", options.addr))?;
    let actual_addr = listener.local_addr()?;
    println!("A3S Code API:  http://{actual_addr}/api/health");
    if options.api_only {
        println!("A3S Code Web:  disabled (--api-only)");
    } else {
        println!("A3S Code Web:  http://{actual_addr}/");
    }
    println!("Workspace:     {}", options.workspace.display());
    println!("Config:        {config_path}");
    println!("Press Ctrl+C to stop.");

    let serve_result = axum::serve(listener, router)
        .with_graceful_shutdown(async {
            let _ = tokio::signal::ctrl_c().await;
        })
        .await
        .map_err(|e| anyhow::anyhow!("server failed: {e}"));
    let shutdown_result = app.shutdown().await.map_err(boot_to_anyhow);

    match (serve_result, shutdown_result) {
        (Err(error), _) => Err(error),
        (Ok(()), Err(error)) => Err(error),
        (Ok(()), Ok(())) => Ok(()),
    }
}

fn ensure_config_path() -> anyhow::Result<String> {
    if let Some(path) = config::find_config() {
        return Ok(path);
    }

    let path = config::default_config_path().ok_or_else(|| anyhow::anyhow!("HOME is not set"))?;
    config::write_template_config(&path)?;
    anyhow::bail!(
        "created starter config at {}; fill in a provider/model, then rerun `a3s code serve`",
        path.display()
    );
}

fn boot_to_anyhow(error: BootError) -> anyhow::Error {
    anyhow::anyhow!("{error}")
}