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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 11:03:55 -05:00
parent 2eae4224e1
commit 73d2d7918c
6 changed files with 496 additions and 5 deletions

1
Cargo.lock generated
View file

@ -277,6 +277,7 @@ dependencies = [
"dotenvy",
"futures",
"http",
"httpmock",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",

View file

@ -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<String>,
pub tls: Option<ClientTlsConfig>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct AgentDefaults {
pub provider: Option<String>,
@ -19,10 +40,77 @@ pub struct LlmDefaults {
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliConfig {
pub mode: Option<ExecutionMode>,
pub server: Option<ServerDefaults>,
pub agent: Option<AgentDefaults>,
pub llm: Option<LlmDefaults>,
}
#[derive(Debug, PartialEq)]
pub struct ResolvedMode {
pub mode: ExecutionMode,
pub server_base_url: String,
pub tls: Option<ClientTlsConfig>,
}
const DEFAULT_SERVER_URL: &str = "http://localhost:3000";
pub fn resolve_mode(
cli_mode: Option<ExecutionMode>,
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<reqwest::Client> {
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<CliConfig> {
@ -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));
}
}

View file

@ -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<cli_config::ExecutionMode>,
/// Server URL (overrides server.base_url from cli.toml)
#[arg(long, global = true)]
server_url: Option<String>,
#[command(subcommand)]
command: Command,
}
fn parse_execution_mode(s: &str) -> Result<cli_config::ExecutionMode, String> {
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()));

View file

@ -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

View file

@ -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<ModelsCommand>) -> Result<()> {
#[derive(Deserialize)]
struct PaginatedModelsResponse {
data: Vec<ModelInfo>,
}
async fn fetch_models_from_server(
client: &reqwest::Client,
base_url: &str,
provider: Option<&str>,
) -> Result<Vec<ModelInfo>> {
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<ModelsCommand>,
server: Option<ServerConnection>,
) -> Result<()> {
let command = command.unwrap_or(ModelsCommand::List {
provider: None,
query: None,
@ -358,7 +407,12 @@ pub async fn run_models(command: Option<ModelsCommand>) -> 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<ModelsCommand>) -> 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());
}
}

View file

@ -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`.
<Note>
The `[llm]` section only sets the default model. Use `[agent]` to configure provider, permissions, and output format for `arc agent`.
</Note>
## `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"
```