From 73d2d7918cedbd30d045dc50d723a4ad686d397c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 7 Mar 2026 11:03:55 -0500 Subject: [PATCH] Add --mode standalone|server CLI support for arc models list Commands can now delegate to a running Arc API server instead of executing in-process. Adds ExecutionMode, ServerDefaults, and ClientTlsConfig to cli.toml parsing with CLI flag > config > default precedence. The models list command fetches from GET /models when in server mode, with mTLS client certificate auth when [server.tls] is configured. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/arc-cli/src/cli_config.rs | 225 ++++++++++++++++++++++++++- crates/arc-cli/src/main.rs | 36 ++++- crates/arc-llm/Cargo.toml | 2 + crates/arc-llm/src/cli.rs | 179 ++++++++++++++++++++- docs/reference/cli-configuration.mdx | 58 +++++++ 6 files changed, 496 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d776213db..0733146ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,7 @@ dependencies = [ "dotenvy", "futures", "http", + "httpmock", "rand 0.8.5", "reqwest 0.12.28", "serde", diff --git a/crates/arc-cli/src/cli_config.rs b/crates/arc-cli/src/cli_config.rs index 20338ba85..82d660911 100644 --- a/crates/arc-cli/src/cli_config.rs +++ b/crates/arc-cli/src/cli_config.rs @@ -1,9 +1,30 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use arc_agent::cli::{OutputFormat, PermissionLevel}; use serde::Deserialize; use tracing::debug; +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ExecutionMode { + #[default] + Standalone, + Server, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct ClientTlsConfig { + pub cert: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct ServerDefaults { + pub base_url: Option, + pub tls: Option, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq)] pub struct AgentDefaults { pub provider: Option, @@ -19,10 +40,77 @@ pub struct LlmDefaults { #[derive(Clone, Debug, Default, Deserialize, PartialEq)] pub struct CliConfig { + pub mode: Option, + pub server: Option, pub agent: Option, pub llm: Option, } +#[derive(Debug, PartialEq)] +pub struct ResolvedMode { + pub mode: ExecutionMode, + pub server_base_url: String, + pub tls: Option, +} + +const DEFAULT_SERVER_URL: &str = "http://localhost:3000"; + +pub fn resolve_mode( + cli_mode: Option, + cli_server_url: Option<&str>, + config: &CliConfig, +) -> ResolvedMode { + let mode = cli_mode + .or_else(|| config.mode.clone()) + .unwrap_or_default(); + + let server_defaults = config.server.as_ref(); + + let server_base_url = cli_server_url + .map(String::from) + .or_else(|| server_defaults.and_then(|s| s.base_url.clone())) + .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string()); + + let tls = server_defaults.and_then(|s| s.tls.clone()); + + debug!(mode = ?mode, base_url = %server_base_url, tls = tls.is_some(), "CLI mode resolved"); + + ResolvedMode { + mode, + server_base_url, + tls, + } +} + +pub fn build_server_client(tls: Option<&ClientTlsConfig>) -> anyhow::Result { + let Some(tls) = tls else { + return Ok(reqwest::Client::new()); + }; + + let cert_path = arc_api::tls::expand_tilde(&tls.cert); + let key_path = arc_api::tls::expand_tilde(&tls.key); + let ca_path = arc_api::tls::expand_tilde(&tls.ca); + + let cert_pem = std::fs::read(&cert_path)?; + let key_pem = std::fs::read(&key_path)?; + let ca_pem = std::fs::read(&ca_path)?; + + let mut identity_pem = cert_pem; + identity_pem.push(b'\n'); + identity_pem.extend_from_slice(&key_pem); + + let identity = reqwest::Identity::from_pem(&identity_pem)?; + let ca_cert = reqwest::Certificate::from_pem(&ca_pem)?; + + let client = reqwest::Client::builder() + .use_rustls_tls() + .identity(identity) + .add_root_certificate(ca_cert) + .build()?; + + Ok(client) +} + /// Load CLI config from an explicit path or `~/.arc/cli.toml`, returning defaults if the /// default file doesn't exist. An explicit path that doesn't exist is an error. pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result { @@ -118,4 +206,139 @@ model = "gemini-pro" let result = load_cli_config(Some(&path)); assert!(result.is_err()); } + + // --- ExecutionMode parsing --- + + #[test] + fn parse_mode_server() { + let toml = r#"mode = "server""#; + let config: CliConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.mode, Some(ExecutionMode::Server)); + } + + #[test] + fn parse_mode_standalone() { + let toml = r#"mode = "standalone""#; + let config: CliConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.mode, Some(ExecutionMode::Standalone)); + } + + #[test] + fn parse_mode_absent() { + let config: CliConfig = toml::from_str("").unwrap(); + assert_eq!(config.mode, None); + } + + // --- ServerDefaults parsing --- + + #[test] + fn parse_server_base_url() { + let toml = r#" +[server] +base_url = "https://arc.example.com:3000" +"#; + let config: CliConfig = toml::from_str(toml).unwrap(); + let server = config.server.unwrap(); + assert_eq!( + server.base_url.as_deref(), + Some("https://arc.example.com:3000") + ); + assert_eq!(server.tls, None); + } + + // --- ClientTlsConfig parsing --- + + #[test] + fn parse_server_tls() { + let toml = r#" +[server] +base_url = "https://arc.example.com:3000" + +[server.tls] +cert = "~/.arc/tls/client.crt" +key = "~/.arc/tls/client.key" +ca = "~/.arc/tls/ca.crt" +"#; + let config: CliConfig = toml::from_str(toml).unwrap(); + let tls = config.server.unwrap().tls.unwrap(); + assert_eq!(tls.cert, PathBuf::from("~/.arc/tls/client.crt")); + assert_eq!(tls.key, PathBuf::from("~/.arc/tls/client.key")); + assert_eq!(tls.ca, PathBuf::from("~/.arc/tls/ca.crt")); + } + + // --- resolve_mode precedence --- + + #[test] + fn resolve_mode_defaults_to_standalone() { + let config = CliConfig::default(); + let resolved = resolve_mode(None, None, &config); + assert_eq!(resolved.mode, ExecutionMode::Standalone); + assert_eq!(resolved.server_base_url, DEFAULT_SERVER_URL); + assert_eq!(resolved.tls, None); + } + + #[test] + fn resolve_mode_config_overrides_default() { + let config = CliConfig { + mode: Some(ExecutionMode::Server), + server: Some(ServerDefaults { + base_url: Some("https://config.example.com".to_string()), + tls: None, + }), + ..CliConfig::default() + }; + let resolved = resolve_mode(None, None, &config); + assert_eq!(resolved.mode, ExecutionMode::Server); + assert_eq!(resolved.server_base_url, "https://config.example.com"); + } + + #[test] + fn resolve_mode_cli_overrides_config() { + let config = CliConfig { + mode: Some(ExecutionMode::Standalone), + server: Some(ServerDefaults { + base_url: Some("https://config.example.com".to_string()), + tls: None, + }), + ..CliConfig::default() + }; + let resolved = resolve_mode( + Some(ExecutionMode::Server), + Some("https://cli.example.com"), + &config, + ); + assert_eq!(resolved.mode, ExecutionMode::Server); + assert_eq!(resolved.server_base_url, "https://cli.example.com"); + } + + #[test] + fn resolve_mode_cli_url_overrides_config_url() { + let config = CliConfig { + server: Some(ServerDefaults { + base_url: Some("https://config.example.com".to_string()), + tls: None, + }), + ..CliConfig::default() + }; + let resolved = resolve_mode(None, Some("https://cli.example.com"), &config); + assert_eq!(resolved.server_base_url, "https://cli.example.com"); + } + + #[test] + fn resolve_mode_tls_from_config() { + let tls = ClientTlsConfig { + cert: PathBuf::from("cert.pem"), + key: PathBuf::from("key.pem"), + ca: PathBuf::from("ca.pem"), + }; + let config = CliConfig { + server: Some(ServerDefaults { + base_url: None, + tls: Some(tls.clone()), + }), + ..CliConfig::default() + }; + let resolved = resolve_mode(None, None, &config); + assert_eq!(resolved.tls, Some(tls)); + } } diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index af77ecddd..8bf06cc9b 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -18,10 +18,26 @@ struct Cli { #[arg(long, global = true)] debug: bool, + /// Execution mode: standalone (in-process) or server (delegate to API) + #[arg(long, global = true, value_parser = parse_execution_mode)] + mode: Option, + + /// Server URL (overrides server.base_url from cli.toml) + #[arg(long, global = true)] + server_url: Option, + #[command(subcommand)] command: Command, } +fn parse_execution_mode(s: &str) -> Result { + match s { + "standalone" => Ok(cli_config::ExecutionMode::Standalone), + "server" => Ok(cli_config::ExecutionMode::Server), + _ => Err(format!("invalid mode '{s}', expected 'standalone' or 'server'")), + } +} + #[derive(Subcommand)] enum Command { /// LLM prompt operations @@ -184,7 +200,25 @@ async fn main() -> Result<()> { Command::Parse(args) => { arc_workflows::cli::parse::parse_command(&args)?; } - Command::Models { command } => arc_llm::cli::run_models(command).await?, + Command::Models { command } => { + let cli_config = cli_config::load_cli_config(None)?; + let resolved = cli_config::resolve_mode( + cli.mode, + cli.server_url.as_deref(), + &cli_config, + ); + let server = match resolved.mode { + cli_config::ExecutionMode::Server => { + let client = cli_config::build_server_client(resolved.tls.as_ref())?; + Some(arc_llm::cli::ServerConnection { + client, + base_url: resolved.server_base_url, + }) + } + cli_config::ExecutionMode::Standalone => None, + }; + arc_llm::cli::run_models(command, server).await? + } Command::Serve(args) => { let styles: &'static arc_util::terminal::Styles = Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr())); diff --git a/crates/arc-llm/Cargo.toml b/crates/arc-llm/Cargo.toml index 3e8266426..33c9a10bf 100644 --- a/crates/arc-llm/Cargo.toml +++ b/crates/arc-llm/Cargo.toml @@ -36,3 +36,5 @@ arc-util = { path = "../arc-util" } http = "1" tokio = { workspace = true, features = ["test-util", "macros"] } dotenvy.workspace = true +httpmock = "0.8" +serde_json.workspace = true diff --git a/crates/arc-llm/src/cli.rs b/crates/arc-llm/src/cli.rs index 57ca3c7fe..7bbe19754 100644 --- a/crates/arc-llm/src/cli.rs +++ b/crates/arc-llm/src/cli.rs @@ -7,12 +7,18 @@ use std::time::Duration; use anyhow::{bail, Context, Result}; use clap::{Args, Subcommand}; use futures::StreamExt; +use serde::Deserialize; use arc_util::terminal::Styles; use crate::catalog; use crate::generate::{self, GenerateParams}; -use crate::types::Message; +use crate::types::{Message, ModelInfo}; + +pub struct ServerConnection { + pub client: reqwest::Client, + pub base_url: String, +} #[derive(Args)] pub struct PromptArgs { @@ -348,7 +354,50 @@ pub async fn run_prompt(args: PromptArgs) -> Result<()> { Ok(()) } -pub async fn run_models(command: Option) -> Result<()> { +#[derive(Deserialize)] +struct PaginatedModelsResponse { + data: Vec, +} + +async fn fetch_models_from_server( + client: &reqwest::Client, + base_url: &str, + provider: Option<&str>, +) -> Result> { + let url = format!("{base_url}/models?page[limit]=100"); + tracing::debug!(url = %url, "Fetching models from server"); + + let response = client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to connect to server at {base_url}"))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + bail!("Server returned {status}: {body}"); + } + + let parsed: PaginatedModelsResponse = response + .json() + .await + .context("Failed to parse models response from server")?; + + let mut models = parsed.data; + tracing::debug!(model_count = models.len(), "Models received from server"); + + if let Some(p) = provider { + models.retain(|m| m.provider == p); + } + + Ok(models) +} + +pub async fn run_models( + command: Option, + server: Option, +) -> Result<()> { let command = command.unwrap_or(ModelsCommand::List { provider: None, query: None, @@ -358,7 +407,12 @@ pub async fn run_models(command: Option) -> Result<()> { match command { ModelsCommand::List { provider, query } => { - let mut models = catalog::list_models(provider.as_deref()); + let mut models = match &server { + Some(s) => { + fetch_models_from_server(&s.client, &s.base_url, provider.as_deref()).await? + } + None => catalog::list_models(provider.as_deref()), + }; if let Some(q) = &query { let q_lower = q.to_lowercase(); @@ -374,6 +428,9 @@ pub async fn run_models(command: Option) -> Result<()> { print_models_table(&models, &styles); } ModelsCommand::Test { provider, model } => { + if server.is_some() { + bail!("models test is not supported in server mode"); + } test_models(provider.as_deref(), model.as_deref(), &styles).await?; } } @@ -633,4 +690,120 @@ mod tests { assert_eq!(result.max_tokens, None); assert_eq!(result.provider_options, None); } + + // --- run_models server mode rejection --- + + #[tokio::test] + async fn models_test_rejects_server_mode() { + let server = ServerConnection { + client: reqwest::Client::new(), + base_url: "http://unused".to_string(), + }; + let command = Some(ModelsCommand::Test { + provider: None, + model: None, + }); + let result = run_models(command, Some(server)).await; + let err = result.unwrap_err(); + assert_eq!( + err.to_string(), + "models test is not supported in server mode" + ); + } + + // --- fetch_models_from_server --- + + #[tokio::test] + async fn fetch_models_from_server_parses_response() { + let server = httpmock::MockServer::start_async().await; + let mock = server.mock_async(|when, then| { + when.method("GET").path("/models").query_param("page[limit]", "100"); + then.status(200) + .header("Content-Type", "application/json") + .body(serde_json::json!({ + "data": [{ + "id": "test-model", + "provider": "test-provider", + "family": "test", + "display_name": "Test Model", + "limits": { "context_window": 128000, "max_output": 4096 }, + "training": null, + "features": { "tools": true, "vision": false, "reasoning": false }, + "costs": { "input_cost_per_mtok": 1.0, "output_cost_per_mtok": 2.0, "cache_input_cost_per_mtok": null }, + "estimated_output_tps": 100.0, + "aliases": ["tm"], + "default": false + }], + "meta": { "has_more": false } + }).to_string()); + }).await; + + let client = reqwest::Client::new(); + let models = fetch_models_from_server(&client, &server.url(""), None) + .await + .unwrap(); + + mock.assert_async().await; + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "test-model"); + assert_eq!(models[0].provider, "test-provider"); + } + + #[tokio::test] + async fn fetch_models_from_server_filters_by_provider() { + let server = httpmock::MockServer::start_async().await; + server.mock_async(|when, then| { + when.method("GET").path("/models"); + then.status(200) + .header("Content-Type", "application/json") + .body(serde_json::json!({ + "data": [ + { + "id": "model-a", + "provider": "alpha", + "family": "a", + "display_name": "Model A", + "limits": { "context_window": 8000 }, + "features": { "tools": false, "vision": false, "reasoning": false }, + "costs": {}, + "aliases": [], + "default": false + }, + { + "id": "model-b", + "provider": "beta", + "family": "b", + "display_name": "Model B", + "limits": { "context_window": 8000 }, + "features": { "tools": false, "vision": false, "reasoning": false }, + "costs": {}, + "aliases": [], + "default": false + } + ], + "meta": { "has_more": false } + }).to_string()); + }).await; + + let client = reqwest::Client::new(); + let models = fetch_models_from_server(&client, &server.url(""), Some("alpha")) + .await + .unwrap(); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "model-a"); + } + + #[tokio::test] + async fn fetch_models_from_server_error_on_failure() { + let server = httpmock::MockServer::start_async().await; + server.mock_async(|when, then| { + when.method("GET").path("/models"); + then.status(500).body("internal error"); + }).await; + + let client = reqwest::Client::new(); + let result = fetch_models_from_server(&client, &server.url(""), None).await; + assert!(result.is_err()); + } } diff --git a/docs/reference/cli-configuration.mdx b/docs/reference/cli-configuration.mdx index f0f33b947..b0913f48c 100644 --- a/docs/reference/cli-configuration.mdx +++ b/docs/reference/cli-configuration.mdx @@ -20,6 +20,16 @@ CLI flags always take the highest priority: ## Full example ```toml +mode = "server" + +[server] +base_url = "https://arc.example.com:3000" + +[server.tls] +cert = "~/.arc/tls/client.crt" +key = "~/.arc/tls/client.key" +ca = "~/.arc/tls/ca.crt" + [agent] provider = "anthropic" model = "claude-opus-4-6" @@ -67,3 +77,51 @@ Defaults for `arc llm prompt` and `arc llm chat`. The `[llm]` section only sets the default model. Use `[agent]` to configure provider, permissions, and output format for `arc agent`. + +## `mode` + +Controls whether commands run in-process or delegate to a running Arc API server. + +| Value | Description | +|---|---| +| `"standalone"` | Execute locally (default) | +| `"server"` | Delegate to an Arc API server | + +Override with the `--mode` CLI flag: + +```bash +arc --mode server models list +``` + +## `[server]` section + +Configuration for server mode. + +| Key | Description | Default | +|---|---|---| +| `base_url` | Server URL | `"http://localhost:3000"` | + +Override the URL with the `--server-url` CLI flag: + +```bash +arc --mode server --server-url https://arc.example.com:3000 models list +``` + +### `[server.tls]` section + +Optional mTLS configuration for authenticating with the server. When present, the CLI presents a client certificate during the TLS handshake. + +| Key | Description | +|---|---| +| `cert` | Path to client certificate PEM file | +| `key` | Path to client private key PEM file | +| `ca` | Path to CA certificate PEM file (to verify the server) | + +Paths support `~/` expansion. Example: + +```toml +[server.tls] +cert = "~/.arc/tls/client.crt" +key = "~/.arc/tls/client.key" +ca = "~/.arc/tls/ca.crt" +```