a3s 0.10.6

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::ffi::OsStr;
use std::path::Path;

use a3s_code_core::config::OsConfig;
use anyhow::{bail, Context};
use serde_json::json;
use tokio::io::AsyncReadExt;

use crate::account_providers::AccountProvider;
use crate::cli::args::{AuthArgs, AuthCommand, AuthLoginArgs};
use crate::cli::context::InvocationContext;
use crate::cli::output::render_value;

const MAX_TOKEN_BYTES: u64 = 64 * 1024;

pub(crate) async fn run(args: AuthArgs, context: &InvocationContext) -> anyhow::Result<()> {
    match args.command {
        AuthCommand::List => list(context),
        AuthCommand::Status(args) => status(&args.provider, context).await,
        AuthCommand::Login(args) => login(args, context).await,
        AuthCommand::Logout(args) => logout(&args.provider, context),
    }
}

fn list(context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let os_config = super::config::load_active_config(context)
        .ok()
        .and_then(|(_, config)| config.os);
    let os_signed_in = os_config
        .as_ref()
        .and_then(crate::a3s_os::current_session)
        .is_some();
    let claude_signed_in = AccountProvider::Claude.is_available();
    let codex_signed_in = AccountProvider::Codex.is_available();
    let kimi_signed_in = AccountProvider::Kimi.is_available();
    let workbuddy_signed_in = AccountProvider::CodeBuddy.is_available();
    let providers = json!([
        {
            "id": "os",
            "ownership": "managed",
            "configured": os_config.is_some(),
            "signedIn": os_signed_in,
        },
        {
            "id": "claude-code",
            "ownership": "external",
            "configured": true,
            "signedIn": claude_signed_in,
        },
        {
            "id": "codex",
            "ownership": "external",
            "configured": true,
            "signedIn": codex_signed_in,
        },
        {
            "id": "kimi",
            "ownership": "external",
            "configured": true,
            "signedIn": kimi_signed_in,
        },
        {
            "id": "workbuddy",
            "ownership": "external",
            "configured": true,
            "signedIn": workbuddy_signed_in,
        }
    ]);
    render_value(output, "auth.list", json!({"providers": providers}), || {
        println!("PROVIDER       OWNERSHIP  STATUS");
        println!(
            "os             managed    {}",
            if os_signed_in {
                "signed in"
            } else if os_config.is_some() {
                "signed out"
            } else {
                "not configured"
            }
        );
        println!(
            "claude-code    external   {}",
            if claude_signed_in {
                "signed in"
            } else {
                "signed out"
            }
        );
        println!(
            "codex          external   {}",
            if codex_signed_in {
                "signed in"
            } else {
                "signed out"
            }
        );
        println!(
            "kimi           external   {}",
            if kimi_signed_in {
                "signed in"
            } else {
                "signed out"
            }
        );
        println!(
            "workbuddy      external   {}",
            if workbuddy_signed_in {
                "signed in"
            } else {
                "signed out"
            }
        );
    })
}

async fn status(provider: &str, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    ensure_os_provider(provider)?;
    let (config_path, os_config) = load_os_config(context)?;
    let mut session = crate::a3s_os::current_session(&os_config);
    if !context.network.offline && session.as_ref().is_some_and(crate::a3s_os::needs_refresh) {
        let refreshed =
            crate::a3s_os::refresh_session(session.as_ref().expect("session was checked above"))
                .await
                .context("could not refresh the OS session")?;
        session = Some(refreshed);
    }
    let signed_in = session.is_some();
    let account = session.as_ref().map(|value| value.display_label());
    let login_at = session
        .as_ref()
        .map(|value| format_unix_ms(value.login_at_ms));
    let expires_at = session
        .as_ref()
        .and_then(|value| value.expires_at_ms)
        .map(format_unix_ms);
    let data = json!({
        "provider": "os",
        "configured": true,
        "signedIn": signed_in,
        "address": os_config.address,
        "account": account,
        "loginAt": login_at,
        "expiresAt": expires_at,
        "configPath": config_path,
    });
    render_value(output, "auth.status", data, || {
        println!("provider: os");
        println!("config: {}", config_path.display());
        println!("address: {}", os_config.address);
        println!(
            "status: {}",
            if signed_in { "signed in" } else { "signed out" }
        );
        if let Some(account) = account {
            println!("account: {account}");
        }
        if let Some(login_at) = login_at {
            println!("login at: {login_at}");
        }
        if let Some(expires_at) = expires_at {
            println!("expires at: {expires_at}");
        }
    })
}

async fn login(args: AuthLoginArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let provider_is_os = args
        .provider_or_legacy
        .as_deref()
        .is_none_or(|value| value == OsStr::new("os"));
    if !provider_is_os || !args.legacy_values.is_empty() {
        bail!(
            "positional credentials are not accepted and only provider `os` is currently managed; use `a3s auth login os --token-stdin`"
        );
    }

    let (_, os_config) = load_os_config(context)?;
    let session = if args.token_stdin {
        let token = read_token_stdin().await?;
        crate::a3s_os::login_with_token(&os_config, &token).await?
    } else if let Some(path) = args.token_file {
        let token = read_protected_token_file(&path)?;
        crate::a3s_os::login_with_token(&os_config, &token).await?
    } else {
        if context.network.offline {
            bail!("browser login is unavailable in offline mode");
        }
        if context.interaction.non_interactive {
            bail!(
                "browser login requires an interactive invocation; use --token-stdin or --token-file"
            );
        }
        crate::a3s_os::login_via_browser(os_config.clone()).await?
    };

    let skill_ready = crate::a3s_os::ensure_capability_skill_dir(&os_config).is_some();
    let account = session.display_label();
    let address = session.address.clone();
    let ssh = if context.network.offline {
        "not synchronized (offline)".to_string()
    } else {
        ssh_key_result(crate::a3s_os::sync_ssh_key(session).await)
    };
    let data = json!({
        "provider": "os",
        "signedIn": true,
        "account": account,
        "address": address,
        "capabilitySkill": skill_ready,
        "sshKey": ssh,
    });
    render_value(output, "auth.login", data, || {
        println!("signed in to OS as {account}");
        println!(
            "capabilities skill: {}",
            if skill_ready {
                "active"
            } else {
                "not installed"
            }
        );
        println!("ssh key: {ssh}");
    })
}

fn logout(provider: &str, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    ensure_os_provider(provider)?;
    let (_, os_config) = load_os_config(context)?;
    let removed = crate::a3s_os::logout(&os_config)?;
    crate::a3s_os::remove_capability_skill_dir();
    let data = json!({
        "provider": "os",
        "signedOut": removed,
        "address": os_config.address,
    });
    render_value(output, "auth.logout", data, || {
        if removed {
            println!("signed out from OS");
        } else {
            println!("no stored OS login for {}", os_config.address);
        }
    })
}

fn ensure_os_provider(provider: &str) -> anyhow::Result<()> {
    if provider == "os" {
        Ok(())
    } else {
        bail!("only the managed `os` authentication provider is currently supported")
    }
}

fn load_os_config(context: &InvocationContext) -> anyhow::Result<(std::path::PathBuf, OsConfig)> {
    let (path, config) = super::config::load_active_config(context)?;
    let os = config.os.ok_or_else(|| {
        anyhow::anyhow!(
            "OS is not configured in {}; add `os = \"https://your-os-host\"`",
            path.display()
        )
    })?;
    Ok((path, os))
}

async fn read_token_stdin() -> anyhow::Result<String> {
    let mut input = Vec::new();
    let mut stdin = tokio::io::stdin().take(MAX_TOKEN_BYTES + 1);
    stdin
        .read_to_end(&mut input)
        .await
        .context("could not read protected token input from stdin")?;
    decode_token(input)
}

fn read_protected_token_file(path: &Path) -> anyhow::Result<String> {
    let metadata = std::fs::symlink_metadata(path)
        .with_context(|| format!("could not inspect token file {}", path.display()))?;
    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
        bail!("token file must be a regular file and must not be a symbolic link");
    }
    if metadata.len() > MAX_TOKEN_BYTES {
        bail!("token input exceeds the maximum supported size");
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if metadata.permissions().mode() & 0o077 != 0 {
            bail!("token file permissions are too broad; use mode 0600");
        }
    }
    let input = std::fs::read(path)
        .with_context(|| format!("could not read token file {}", path.display()))?;
    decode_token(input)
}

fn decode_token(input: Vec<u8>) -> anyhow::Result<String> {
    if input.len() as u64 > MAX_TOKEN_BYTES {
        bail!("token input exceeds the maximum supported size");
    }
    let token = String::from_utf8(input).context("token input must be UTF-8")?;
    if token.trim().is_empty() {
        bail!("token input is empty");
    }
    Ok(token)
}

fn ssh_key_result(outcome: crate::a3s_os::SshKeyOutcome) -> String {
    match outcome {
        crate::a3s_os::SshKeyOutcome::Registered(fingerprint) => {
            format!("registered ({fingerprint})")
        }
        crate::a3s_os::SshKeyOutcome::AlreadyRegistered => "already registered".to_string(),
        crate::a3s_os::SshKeyOutcome::NoLocalKey => "not found locally".to_string(),
        crate::a3s_os::SshKeyOutcome::Failed(error) => format!("sync skipped: {error}"),
    }
}

fn format_unix_ms(ms: u64) -> String {
    let seconds = (ms / 1000) as i64;
    let nanoseconds = ((ms % 1000) * 1_000_000) as u32;
    chrono::DateTime::<chrono::Utc>::from_timestamp(seconds, nanoseconds)
        .map(|value| value.to_rfc3339())
        .unwrap_or_else(|| ms.to_string())
}