a3s 0.9.2

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;

use anyhow::{bail, Context};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncSeekExt};

use crate::api::serve::{WebInstanceRecord, WebInstanceStatus};
use crate::cli::args::{OutputMode, WebArgs, WebCommand, WebLogsArgs, WebStartArgs, WebTargetArgs};
use crate::cli::context::InvocationContext;
use crate::cli::output::{render_value, usage_error, write_jsonl, CliError, ExitClass};

pub(crate) async fn run(args: WebArgs, context: &InvocationContext) -> anyhow::Result<()> {
    match args.command {
        Some(WebCommand::Start(start)) => start_web(start, context).await,
        Some(WebCommand::Stop(target)) => stop(target, context).await,
        Some(WebCommand::Status(target)) => status(target, context).await,
        Some(WebCommand::Logs(args)) => logs(args, context).await,
        Some(WebCommand::Open(target)) => open(target, context).await,
        None => start_web(args.shortcut, context).await,
    }
}

async fn start_web(args: WebStartArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    if output != OutputMode::Human && !args.detach {
        return Err(usage_error(
            "machine output for foreground `a3s web start` is not supported; use `--detach`",
        ));
    }
    let argv = start_argv(args, context)?;
    match crate::api::run_web(&argv).await? {
        crate::api::ServeOutcome::Detached(instance) => {
            let url = (!instance.api_only).then(|| format!("http://{}/", instance.address));
            let api_url = format!("http://{}/api/health", instance.address);
            render_value(
                output,
                "web.start",
                json!({
                    "detached": true,
                    "pid": instance.pid,
                    "url": url,
                    "apiUrl": api_url,
                    "workspace": instance.workspace,
                    "logPath": instance.log_path,
                }),
                || {
                    if let Some(url) = url {
                        println!("A3S Web:       {url}");
                    } else {
                        println!("A3S Web:       disabled (--api-only)");
                    }
                    println!("A3S Code API:  {api_url}");
                    println!("Background PID: {}", instance.pid);
                    println!("Log:            {}", instance.log_path.display());
                },
            )
        }
        crate::api::ServeOutcome::Help | crate::api::ServeOutcome::ForegroundStopped => Ok(()),
    }
}

async fn status(target: WebTargetArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let workspace = target_workspace(target, context)?;
    let status = crate::api::serve::instance_status(&workspace).await?;
    render_status(status, workspace, output)
}

async fn stop(target: WebTargetArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let workspace = target_workspace(target, context)?;
    let stopped = crate::api::serve::stop_instance(&workspace).await?;
    let data = match &stopped {
        Some(instance) => json!({
            "stopped": true,
            "pid": instance.pid,
            "address": instance.address,
            "workspace": instance.workspace,
        }),
        None => json!({
            "stopped": false,
            "reason": "not-running",
            "workspace": workspace,
        }),
    };
    render_value(output, "web.stop", data, || match stopped {
        Some(instance) => println!(
            "stopped A3S Web for {} (pid {})",
            instance.workspace.display(),
            instance.pid
        ),
        None => println!("A3S Web is not running for {}", workspace.display()),
    })
}

async fn logs(args: WebLogsArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    if args.lines == 0 {
        return Err(usage_error("--lines must be greater than zero"));
    }
    if args.follow && output == OutputMode::Json {
        return Err(usage_error(
            "`a3s web logs --follow` requires human or JSONL output",
        ));
    }
    let workspace = target_workspace(args.target, context)?;
    let status = crate::api::serve::instance_status(&workspace).await?;
    let instance = status
        .instance
        .ok_or_else(|| anyhow::anyhow!("A3S Web has no managed log for {}", workspace.display()))?;
    let existing = crate::api::serve::read_log_tail(&instance.log_path, args.lines)?;

    let mut next_sequence = 1;
    match output {
        OutputMode::Human => print!("{existing}"),
        OutputMode::Json => {
            render_value(
                output,
                "web.logs",
                json!({"path": instance.log_path, "content": existing}),
                || {},
            )?;
        }
        OutputMode::Jsonl => {
            next_sequence = emit_log_lines(&existing, next_sequence)?;
        }
    }
    if args.follow {
        follow_log(&instance, output, next_sequence, &context.cancellation).await?;
    } else if output == OutputMode::Jsonl {
        emit_log_result(next_sequence, &instance.log_path, false)?;
    }
    Ok(())
}

async fn open(target: WebTargetArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let workspace = target_workspace(target, context)?;
    let instance = crate::api::serve::open_instance(&workspace).await?;
    let url = format!("http://{}/", instance.address);
    if output == OutputMode::Human && !instance.api_only {
        open_url(&url, context)?;
    }
    render_value(
        output,
        "web.open",
        json!({"url": url, "opened": output == OutputMode::Human && !instance.api_only}),
        || {
            if instance.api_only {
                println!(
                    "A3S Web UI is disabled; API: http://{}/api/health",
                    instance.address
                );
            } else {
                println!("opened {url}");
            }
        },
    )
}

fn render_status(
    status: WebInstanceStatus,
    workspace: PathBuf,
    output: OutputMode,
) -> anyhow::Result<()> {
    let instance_data = status.instance.as_ref().map(|instance| {
        json!({
            "pid": instance.pid,
            "address": instance.address,
            "workspace": instance.workspace,
            "logPath": instance.log_path,
            "startedAtMs": instance.started_at_ms,
            "apiOnly": instance.api_only,
        })
    });
    let data = json!({
        "running": status.running,
        "stale": status.stale,
        "workspace": workspace,
        "instance": instance_data,
    });
    render_value(output, "web.status", data, || {
        if status.running {
            if let Some(instance) = status.instance {
                println!("running");
                println!("url: http://{}/", instance.address);
                println!("pid: {}", instance.pid);
                println!("workspace: {}", instance.workspace.display());
                println!("log: {}", instance.log_path.display());
            }
        } else if status.stale {
            println!("stale instance record for {}", workspace.display());
        } else {
            println!("not running for {}", workspace.display());
        }
    })
}

fn start_argv(args: WebStartArgs, context: &InvocationContext) -> anyhow::Result<Vec<String>> {
    let output = context.output_mode();
    let mut argv = Vec::new();
    if args.detach {
        argv.push("--detach".to_string());
    }
    if let Some(host) = args.host {
        argv.extend(["--host".to_string(), host]);
    }
    if let Some(port) = args.port {
        argv.extend(["--port".to_string(), port.to_string()]);
    }
    if let Some(workspace) = args.legacy_workspace {
        if output == OutputMode::Human {
            eprintln!("warning: `--workspace` is deprecated; use global `--directory`/`-C`");
        }
        let workspace = context.resolve_path(workspace);
        argv.extend([
            "--workspace".to_string(),
            path_to_string(&workspace, "workspace")?,
        ]);
    } else {
        argv.extend([
            "--workspace".to_string(),
            path_to_string(&context.directory, "workspace")?,
        ]);
    }
    if let Some(config) = context.explicit_config.as_deref() {
        argv.extend([
            "--config".to_string(),
            path_to_string(config, "config path")?,
        ]);
    }
    if let Some(web_dir) = args.web_dir {
        argv.extend([
            "--web-dir".to_string(),
            path_to_string(&web_dir, "Web asset directory")?,
        ]);
    }
    if args.api_only {
        argv.push("--api-only".to_string());
    }
    Ok(argv)
}

fn target_workspace(target: WebTargetArgs, context: &InvocationContext) -> anyhow::Result<PathBuf> {
    let output = context.output_mode();
    if let Some(workspace) = target.legacy_workspace {
        if output == OutputMode::Human {
            eprintln!("warning: `--workspace` is deprecated; use global `--directory`/`-C`");
        }
        Ok(context.resolve_path(workspace))
    } else {
        Ok(context.directory.clone())
    }
}

fn path_to_string(path: &Path, label: &str) -> anyhow::Result<String> {
    path.to_str()
        .map(str::to_string)
        .with_context(|| format!("{label} must be valid UTF-8"))
}

fn open_url(url: &str, context: &InvocationContext) -> anyhow::Result<()> {
    #[cfg(target_os = "macos")]
    let mut command = {
        let mut command = Command::new("open");
        command.arg(url);
        command
    };
    #[cfg(all(unix, not(target_os = "macos")))]
    let mut command = {
        let mut command = Command::new("xdg-open");
        command.arg(url);
        command
    };
    #[cfg(windows)]
    let mut command = {
        let mut command = Command::new("cmd");
        command.args(["/C", "start", "", url]);
        command
    };
    let status = command
        .current_dir(&context.directory)
        .status()
        .with_context(|| format!("could not open {url}"))?;
    if !status.success() {
        bail!("browser opener exited with {status}");
    }
    Ok(())
}

fn emit_log_lines(content: &str, start_sequence: u64) -> anyhow::Result<u64> {
    let mut sequence = start_sequence;
    for line in content.lines() {
        write_jsonl(&json!({
            "schemaVersion": 1,
            "command": "web.logs",
            "type": "log",
            "sequence": sequence,
            "line": line,
        }))
        .map_err(|error| log_stream_error(error, sequence))?;
        sequence += 1;
    }
    Ok(sequence)
}

fn emit_log_result(sequence: u64, path: &Path, followed: bool) -> anyhow::Result<()> {
    write_jsonl(&json!({
        "schemaVersion": 1,
        "command": "web.logs",
        "type": "result",
        "sequence": sequence,
        "ok": true,
        "data": {"path": path, "followed": followed},
    }))
    .map_err(|error| log_stream_error(error, sequence))
}

async fn follow_log(
    instance: &WebInstanceRecord,
    output: OutputMode,
    mut sequence: u64,
    cancellation: &tokio_util::sync::CancellationToken,
) -> anyhow::Result<()> {
    let mut file = tokio::fs::File::open(&instance.log_path)
        .await
        .with_context(|| format!("could not open {}", instance.log_path.display()))
        .map_err(|error| follow_error(error, output, sequence))?;
    let mut offset = file
        .metadata()
        .await
        .map_err(anyhow::Error::from)
        .map_err(|error| follow_error(error, output, sequence))?
        .len();
    loop {
        tokio::select! {
            _ = cancellation.cancelled() => {
                return Err(CliError::new(
                    "operation.cancelled",
                    "Web log following cancelled",
                    ExitClass::Cancelled,
                )
                .with_jsonl_sequence(sequence)
                .into());
            },
            _ = tokio::time::sleep(Duration::from_millis(250)) => {}
        }
        let length = file
            .metadata()
            .await
            .map_err(anyhow::Error::from)
            .map_err(|error| follow_error(error, output, sequence))?
            .len();
        if length < offset {
            offset = 0;
        }
        if length == offset {
            continue;
        }
        file.seek(std::io::SeekFrom::Start(offset))
            .await
            .map_err(anyhow::Error::from)
            .map_err(|error| follow_error(error, output, sequence))?;
        let mut bytes = Vec::new();
        file.read_to_end(&mut bytes)
            .await
            .map_err(anyhow::Error::from)
            .map_err(|error| follow_error(error, output, sequence))?;
        offset = length;
        let content = String::from_utf8_lossy(&bytes);
        match output {
            OutputMode::Human => print!("{content}"),
            OutputMode::Jsonl => sequence = emit_log_lines(&content, sequence)?,
            OutputMode::Json => unreachable!("JSON follow is rejected before this function"),
        }
    }
}

fn follow_error(error: anyhow::Error, output: OutputMode, sequence: u64) -> anyhow::Error {
    if output == OutputMode::Jsonl {
        log_stream_error(error, sequence)
    } else {
        error
    }
}

fn log_stream_error(error: anyhow::Error, sequence: u64) -> anyhow::Error {
    if error.downcast_ref::<CliError>().is_some() {
        return error;
    }
    CliError::new(
        "web.logs.failed",
        format!("Web log stream failed: {error:#}"),
        ExitClass::Failure,
    )
    .with_jsonl_sequence(sequence)
    .into()
}