a3s 0.10.6

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

use a3s_code_core::config::ConfigSection;
use a3s_code_core::CodeConfig;
use anyhow::bail;
use serde_json::{json, Value};

use crate::cli::args::{ModelArgs, ModelCommand, ModelScopeArgs, ModelUseArgs};
use crate::cli::context::InvocationContext;
use crate::cli::output::render_value;
use crate::model::catalog::{ModelCatalog, ModelEntry};
use crate::model::route::ModelRoute;

pub(crate) async fn run(args: ModelArgs, context: &InvocationContext) -> anyhow::Result<()> {
    match args.command {
        ModelCommand::List => list(context).await,
        ModelCommand::Current => current(context),
        ModelCommand::Use(args) => select(args, context).await,
        ModelCommand::Reset(args) => reset(args, context),
    }
}

async fn list(context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let (_, config) = super::config::load_active_config(context)?;
    let current = config.default_model.clone();
    let catalog = ModelCatalog::discover_with_config(&config, true).await;
    let models = discovered_models(&catalog.entries, current.as_deref());
    let warnings = catalog.warnings;
    render_value(
        output,
        "model.list",
        json!({"current": current, "models": models, "warnings": warnings}),
        || {
            println!(
                "current: {}",
                current.as_deref().unwrap_or("(not selected)")
            );
            if models.is_empty() {
                println!("no runtime-callable models were discovered");
                return;
            }
            println!("MODEL                                      SOURCE");
            for model in &models {
                let id = model.get("id").and_then(Value::as_str).unwrap_or_default();
                let source = model
                    .get("source")
                    .and_then(Value::as_str)
                    .unwrap_or_default();
                let marker = if model
                    .get("current")
                    .and_then(Value::as_bool)
                    .unwrap_or(false)
                {
                    "*"
                } else {
                    " "
                };
                println!("{marker} {id:<40} {source}");
            }
            for warning in &warnings {
                eprintln!("warning: {warning}");
            }
        },
    )
}

fn current(context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let effective = super::config::resolve_effective_config(context)?;
    let path = effective.primary_path;
    let model = effective.config.default_model.clone();
    let source = model
        .as_deref()
        .and_then(|model| model.parse::<ModelRoute>().ok())
        .map(|route| route.source.label());
    let config_source = effective.provenance.get("default_model").cloned();
    render_value(
        output,
        "model.current",
        json!({"model": model, "source": source, "configSource": config_source, "configPath": path}),
        || {
            println!("{}", model.as_deref().unwrap_or("(not selected)"));
            println!("config: {}", path.display());
        },
    )
}

async fn select(args: ModelUseArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let route: ModelRoute = args.model.parse()?;
    let selected_model = route.to_string();
    let (_, effective) = super::config::load_active_config(context)?;
    if !ModelCatalog::route_available_with_config(&route, &effective).await {
        bail!(
            "model `{}` is not available; run `a3s model list` to inspect runtime-callable models",
            selected_model
        );
    }

    let path = super::config::target_config_path(args.target.scope, context)?;
    let mut config = load_target_config(&path)?;
    config.default_model = Some(selected_model.clone());
    crate::api::code_web::config::persistence::persist_config_sections(
        &path,
        &config,
        &[ConfigSection::DefaultModel],
    )
    .map_err(|error| anyhow::anyhow!("could not update {}: {error}", path.display()))?;

    let data = json!({"model": selected_model, "configPath": path});
    render_value(output, "model.use", data, || {
        println!("default model: {selected_model}");
        println!("config: {}", path.display());
    })
}

fn reset(args: ModelScopeArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let path = super::config::target_config_path(args.scope, context)?;
    let mut config = load_target_config(&path)?;
    let previous = config.default_model.take();
    crate::api::code_web::config::persistence::persist_config_sections(
        &path,
        &config,
        &[ConfigSection::DefaultModel],
    )
    .map_err(|error| anyhow::anyhow!("could not update {}: {error}", path.display()))?;
    render_value(
        output,
        "model.reset",
        json!({"previous": previous, "configPath": path}),
        || {
            if let Some(previous) = previous {
                println!("removed default model `{previous}`");
            } else {
                println!("no default model was selected in {}", path.display());
            }
        },
    )
}

fn load_target_config(path: &Path) -> anyhow::Result<CodeConfig> {
    if !path.exists() {
        return Ok(CodeConfig::default());
    }
    CodeConfig::from_file(path)
        .map_err(|error| anyhow::anyhow!("failed to parse A3S ACL {}: {error}", path.display()))
}

fn discovered_models(entries: &[ModelEntry], current: Option<&str>) -> Vec<Value> {
    let mut models = entries
        .iter()
        .map(|entry| {
            let id = entry.route.to_string();
            json!({
                "id": id,
                "name": entry.display_name,
                "source": entry.route.source.label(),
                "current": current == Some(id.as_str()),
                "reasoning": entry.reasoning,
                "toolCall": entry.tool_call,
                "contextWindow": entry.context_window,
            })
        })
        .collect::<Vec<_>>();
    models.sort_by(|left, right| {
        left.get("id")
            .and_then(Value::as_str)
            .cmp(&right.get("id").and_then(Value::as_str))
    });
    models
}