Add arc-config crate and make arc-api optional via server feature flag

Centralizes config types (ServerConfig, CliConfig, expand_tilde) into a
new arc-config crate. Makes arc-api an optional dependency of arc-cli
behind a default-on "server" feature flag, so CLI-only builds skip heavy
server deps (axum, tower, hyper, sqlx, arc-db, arc-types).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-09 23:50:41 -04:00
parent 1adc580e43
commit 11414c15b2
18 changed files with 527 additions and 437 deletions

17
Cargo.lock generated
View file

@ -135,6 +135,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"arc-agent",
"arc-config",
"arc-db",
"arc-exe",
"arc-github",
@ -185,6 +186,7 @@ dependencies = [
"anyhow",
"arc-agent",
"arc-api",
"arc-config",
"arc-github",
"arc-llm",
"arc-mcp",
@ -221,6 +223,21 @@ dependencies = [
"x509-parser",
]
[[package]]
name = "arc-config"
version = "0.1.0"
dependencies = [
"anyhow",
"arc-agent",
"arc-mcp",
"arc-workflows",
"dirs",
"serde",
"tempfile",
"toml",
"tracing",
]
[[package]]
name = "arc-db"
version = "0.1.0"

View file

@ -9,6 +9,7 @@ description = "HTTP API server for Arc pipelines"
doctest = false
[dependencies]
arc-config = { path = "../arc-config" }
arc-workflows = { path = "../arc-workflows" }
arc-github = { path = "../arc-github" }
arc-agent = { path = "../arc-agent" }

View file

@ -3054,7 +3054,7 @@ mod insights {
}
mod settings {
use crate::server_config::*;
use arc_config::server::*;
pub fn server_config() -> serde_json::Value {
serde_json::to_value(ServerConfig {

View file

@ -68,10 +68,10 @@ pub fn decode_pem_env(name: &str, value: &str) -> String {
/// Call this once at startup before serving requests. Panics if the
/// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config).
pub fn resolve_auth_mode(
api_config: &crate::server_config::ApiConfig,
api_config: &arc_config::server::ApiConfig,
allowed_usernames: Vec<String>,
) -> AuthMode {
use crate::server_config::ApiAuthStrategy;
use arc_config::server::ApiAuthStrategy;
if api_config.authentication_strategies.is_empty() {
warn!("No authentication strategies configured; all requests will be rejected");

View file

@ -4,6 +4,8 @@ pub mod github_webhooks;
pub mod jwt_auth;
pub mod serve;
pub mod server;
pub mod server_config;
pub mod server_config {
pub use arc_config::server::*;
}
pub mod sessions;
pub mod tls;

View file

@ -9,9 +9,10 @@ use tracing::{error, info, warn};
use clap::Args;
use arc_config::server::ServerConfig;
use crate::jwt_auth::{AuthMode, AuthStrategy};
use crate::server::build_router;
use crate::server_config::ServerConfig;
use crate::tls::ClientAuth;
use arc_workflows::cli::backend::AgentApiBackend;
use arc_workflows::cli::SandboxProvider;
@ -84,8 +85,8 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
// Initialize data directory and SQLite database
let config_path = args.config;
let server_config = crate::server_config::load_server_config(config_path.as_deref())?;
let data_dir = crate::server_config::resolve_data_dir(&server_config);
let server_config = arc_config::server::load_server_config(config_path.as_deref())?;
let data_dir = arc_config::server::resolve_data_dir(&server_config);
// Shared config for live reloading
let shared_config = Arc::new(RwLock::new(server_config));
@ -212,7 +213,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
interval.tick().await; // skip first immediate tick
loop {
interval.tick().await;
match crate::server_config::load_server_config(config_path_for_poll.as_deref()) {
match arc_config::server::load_server_config(config_path_for_poll.as_deref()) {
Ok(new_config) => {
let changed = {
let cfg = config_for_poll.read().expect("config lock poisoned");

View file

@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use rustls::server::WebPkiClientVerifier;
@ -7,8 +7,9 @@ use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use tokio::net::TcpListener;
use tracing::error;
use arc_config::server::TlsConfig;
use crate::jwt_auth::PeerCertificates;
use crate::server_config::TlsConfig;
/// How client certificates should be verified.
pub enum ClientAuth {
@ -108,15 +109,7 @@ pub async fn serve_tls(
}
}
/// Expand `~/` prefix to the user's home directory.
pub fn expand_tilde(path: &Path) -> PathBuf {
if let Ok(rest) = path.strip_prefix("~") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
path.to_path_buf()
}
pub use arc_config::expand_tilde;
fn load_certs(path: &Path) -> Vec<CertificateDer<'static>> {
let path = expand_tilde(path);

View file

@ -9,13 +9,18 @@ description = "Unified CLI for the Arc AI framework"
name = "arc"
path = "src/main.rs"
[features]
default = ["server"]
server = ["dep:arc-api"]
[dependencies]
arc-config = { path = "../arc-config" }
arc-llm = { path = "../arc-llm" }
arc-github = { path = "../arc-github" }
arc-agent = { path = "../arc-agent" }
arc-mcp = { path = "../arc-mcp" }
arc-workflows = { path = "../arc-workflows" }
arc-api = { path = "../arc-api" }
arc-api = { path = "../arc-api", optional = true }
arc-util = { path = "../arc-util" }
bollard.workspace = true
clap.workspace = true

View file

@ -1,89 +1,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub use arc_config::cli::*;
use arc_agent::cli::{OutputFormat, PermissionLevel};
use arc_mcp::config::{McpServerConfig, McpTransport};
use arc_workflows::cli::run_config::PullRequestConfig;
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 ExecDefaults {
pub provider: Option<String>,
pub model: Option<String>,
pub permissions: Option<PermissionLevel>,
pub output_format: Option<OutputFormat>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct LlmDefaults {
pub model: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliGitConfig {
#[serde(default)]
pub author: arc_api::server_config::GitAuthorConfig,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct McpServerEntry {
#[serde(flatten)]
pub transport: McpTransport,
#[serde(default = "arc_mcp::config::default_startup_timeout_secs")]
pub startup_timeout_secs: u64,
#[serde(default = "arc_mcp::config::default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
}
impl McpServerEntry {
pub fn into_config(self, name: String) -> McpServerConfig {
McpServerConfig {
name,
transport: self.transport,
startup_timeout_secs: self.startup_timeout_secs,
tool_timeout_secs: self.tool_timeout_secs,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliConfig {
pub mode: Option<ExecutionMode>,
pub server: Option<ServerDefaults>,
pub exec: Option<ExecDefaults>,
pub llm: Option<LlmDefaults>,
pub git: Option<CliGitConfig>,
#[serde(default)]
pub verbose: bool,
#[serde(default)]
pub log: arc_api::server_config::LogConfig,
pub pull_request: Option<PullRequestConfig>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
}
#[derive(Debug, PartialEq)]
pub struct ResolvedMode {
pub mode: ExecutionMode,
@ -123,9 +41,9 @@ pub fn build_server_client(tls: Option<&ClientTlsConfig>) -> anyhow::Result<reqw
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_path = arc_config::expand_tilde(&tls.cert);
let key_path = arc_config::expand_tilde(&tls.key);
let ca_path = arc_config::expand_tilde(&tls.ca);
let cert_pem = std::fs::read(&cert_path)?;
let key_pem = std::fs::read(&key_path)?;
@ -147,161 +65,12 @@ pub fn build_server_client(tls: Option<&ClientTlsConfig>) -> anyhow::Result<reqw
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> {
if let Some(explicit) = path {
debug!(path = %explicit.display(), "Loading CLI config from explicit path");
let contents = std::fs::read_to_string(explicit)?;
return Ok(toml::from_str(&contents)?);
}
let Some(home) = dirs::home_dir() else {
debug!("No home directory found, using default CLI config");
return Ok(CliConfig::default());
};
let default_path = home.join(".arc").join("cli.toml");
debug!(path = %default_path.display(), "Loading CLI config");
match std::fs::read_to_string(&default_path) {
Ok(contents) => Ok(toml::from_str(&contents)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CliConfig::default()),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn parse_empty_config_defaults() {
let config: CliConfig = toml::from_str("").unwrap();
assert_eq!(config, CliConfig::default());
}
#[test]
fn parse_full_config() {
let toml = r#"
[exec]
provider = "anthropic"
model = "claude-opus-4-6"
permissions = "read-write"
output_format = "text"
[llm]
model = "claude-sonnet-4-5"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let exec = config.exec.unwrap();
assert_eq!(exec.provider.as_deref(), Some("anthropic"));
assert_eq!(exec.model.as_deref(), Some("claude-opus-4-6"));
assert_eq!(exec.permissions, Some(PermissionLevel::ReadWrite));
assert_eq!(exec.output_format, Some(OutputFormat::Text));
let llm = config.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5"));
}
#[test]
fn parse_partial_exec_config() {
let toml = r#"
[exec]
provider = "openai"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let exec = config.exec.unwrap();
assert_eq!(exec.provider.as_deref(), Some("openai"));
assert_eq!(exec.model, None);
assert_eq!(exec.permissions, None);
assert_eq!(exec.output_format, None);
assert_eq!(config.llm, None);
}
#[test]
fn load_cli_config_from_explicit_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.toml");
std::fs::write(
&path,
r#"
[exec]
provider = "gemini"
model = "gemini-pro"
"#,
)
.unwrap();
let config = load_cli_config(Some(&path)).unwrap();
let exec = config.exec.unwrap();
assert_eq!(exec.provider.as_deref(), Some("gemini"));
assert_eq!(exec.model.as_deref(), Some("gemini-pro"));
}
#[test]
fn load_cli_config_explicit_path_missing_is_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.toml");
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]
@ -360,38 +129,6 @@ ca = "~/.arc/tls/ca.crt"
assert_eq!(resolved.server_base_url, "https://cli.example.com");
}
#[test]
fn parse_git_author_config() {
let toml = r#"
[git.author]
name = "my-arc"
email = "me@local"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let git = config.git.unwrap();
assert_eq!(git.author.name.as_deref(), Some("my-arc"));
assert_eq!(git.author.email.as_deref(), Some("me@local"));
}
#[test]
fn parse_git_author_absent() {
let config: CliConfig = toml::from_str("").unwrap();
assert_eq!(config.git, None);
}
#[test]
fn parse_verbose_true() {
let config: CliConfig = toml::from_str("verbose = true").unwrap();
assert!(config.verbose);
}
#[test]
fn parse_log_level() {
let toml = "[log]\nlevel = \"debug\"";
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.log.level.as_deref(), Some("debug"));
}
#[test]
fn resolve_mode_tls_from_config() {
let tls = ClientTlsConfig {
@ -409,135 +146,4 @@ email = "me@local"
let resolved = resolve_mode(None, None, &config);
assert_eq!(resolved.tls, Some(tls));
}
#[test]
fn parse_pull_request_enabled() {
let toml = r#"
[pull_request]
enabled = true
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let pr = config.pull_request.unwrap();
assert!(pr.enabled);
}
#[test]
fn parse_pull_request_absent() {
let config: CliConfig = toml::from_str("").unwrap();
assert_eq!(config.pull_request, None);
}
#[test]
fn parse_mcp_stdio_server_with_env_and_timeouts() {
let toml = r#"
[mcp_servers.filesystem]
type = "stdio"
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
startup_timeout_secs = 15
tool_timeout_secs = 90
[mcp_servers.filesystem.env]
NODE_ENV = "production"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.mcp_servers.len(), 1);
let entry = &config.mcp_servers["filesystem"];
assert_eq!(entry.startup_timeout_secs, 15);
assert_eq!(entry.tool_timeout_secs, 90);
match &entry.transport {
McpTransport::Stdio { command, env } => {
assert_eq!(
command,
&[
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/workspace"
]
);
assert_eq!(env.get("NODE_ENV").unwrap(), "production");
}
_ => panic!("expected Stdio transport"),
}
}
#[test]
fn parse_mcp_http_server_with_headers() {
let toml = r#"
[mcp_servers.sentry]
type = "http"
url = "https://mcp.sentry.dev/mcp"
[mcp_servers.sentry.headers]
Authorization = "Bearer sk-xxx"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.mcp_servers.len(), 1);
let entry = &config.mcp_servers["sentry"];
match &entry.transport {
McpTransport::Http { url, headers } => {
assert_eq!(url, "https://mcp.sentry.dev/mcp");
assert_eq!(headers.get("Authorization").unwrap(), "Bearer sk-xxx");
}
_ => panic!("expected Http transport"),
}
}
#[test]
fn parse_mcp_empty_backward_compat() {
let config: CliConfig = toml::from_str("").unwrap();
assert!(config.mcp_servers.is_empty());
}
#[test]
fn parse_mcp_both_transports() {
let toml = r#"
[mcp_servers.local]
type = "stdio"
command = ["python3", "server.py"]
[mcp_servers.remote]
type = "http"
url = "https://mcp.example.com"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.mcp_servers.len(), 2);
assert!(matches!(
config.mcp_servers["local"].transport,
McpTransport::Stdio { .. }
));
assert!(matches!(
config.mcp_servers["remote"].transport,
McpTransport::Http { .. }
));
}
#[test]
fn parse_mcp_defaults_applied_when_timeouts_omitted() {
let toml = r#"
[mcp_servers.minimal]
type = "stdio"
command = ["echo"]
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let entry = &config.mcp_servers["minimal"];
assert_eq!(entry.startup_timeout_secs, 10);
assert_eq!(entry.tool_timeout_secs, 60);
}
#[test]
fn mcp_server_entry_into_config() {
let entry = McpServerEntry {
transport: McpTransport::Stdio {
command: vec!["node".into(), "server.js".into()],
env: HashMap::new(),
},
startup_timeout_secs: 15,
tool_timeout_secs: 90,
};
let config = entry.into_config("my-server".into());
assert_eq!(config.name, "my-server");
assert_eq!(config.startup_timeout_secs, 15);
assert_eq!(config.tool_timeout_secs, 90);
}
}

View file

@ -2,7 +2,7 @@ use std::path::PathBuf;
use std::process::Command;
use std::sync::LazyLock;
use arc_api::server_config::{ApiAuthStrategy, AuthProvider};
use arc_config::server::{ApiAuthStrategy, AuthProvider};
use arc_llm::provider::Provider;
pub use arc_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckStatus};
use arc_util::terminal::Styles;
@ -890,7 +890,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok();
let server_config = arc_api::server_config::load_server_config(None).unwrap_or_default();
let server_config = arc_config::server::load_server_config(None).unwrap_or_default();
let api_status = ApiStatus {
base_url: server_config.api.base_url.clone(),
@ -921,7 +921,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
let tls_files = if has_mtls {
server_config.api.tls.as_ref().map(|tls| {
let read = |p: &std::path::Path| -> Result<String, String> {
let expanded = arc_api::tls::expand_tilde(p);
let expanded = arc_config::expand_tilde(p);
std::fs::read_to_string(&expanded)
.map_err(|e| format!("{}: {e}", expanded.display()))
};

View file

@ -63,6 +63,7 @@ enum Command {
command: Option<arc_llm::cli::ModelsCommand>,
},
/// Start the HTTP API server
#[cfg(feature = "server")]
Serve(arc_api::serve::ServeArgs),
/// Check environment and integration health
Doctor {
@ -113,7 +114,7 @@ enum LlmCommand {
}
fn build_github_app_credentials(
config: &arc_api::server_config::ServerConfig,
config: &arc_config::server::ServerConfig,
) -> Option<arc_github::GitHubAppCredentials> {
let app_id = config.git.app_id.as_ref()?;
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
@ -173,6 +174,7 @@ async fn main_inner() -> Result<()> {
Command::Parse(_) => "parse",
Command::Cp(_) => "cp",
Command::Model { .. } => "model",
#[cfg(feature = "server")]
Command::Serve(_) => "serve",
Command::Doctor { .. } => "doctor",
Command::Setup => "setup",
@ -181,12 +183,20 @@ async fn main_inner() -> Result<()> {
Command::System { .. } => "system",
};
let config_log_level = if let Command::Serve(ref args) = cli.command {
let server_config = arc_api::server_config::load_server_config(args.config.as_deref())?;
server_config.log.level
} else {
let cli_config = cli_config::load_cli_config(None)?;
cli_config.log.level
let config_log_level = {
#[cfg(feature = "server")]
{
if let Command::Serve(ref args) = cli.command {
let server_config = arc_config::server::load_server_config(args.config.as_deref())?;
server_config.log.level
} else {
arc_config::cli::load_cli_config(None)?.log.level
}
}
#[cfg(not(feature = "server"))]
{
arc_config::cli::load_cli_config(None)?.log.level
}
};
let log_prefix = if command_name == "serve" {
@ -296,7 +306,7 @@ async fn main_inner() -> Result<()> {
Command::Run(mut args) => {
let styles: &'static arc_util::terminal::Styles =
Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr()));
let server_config = arc_api::server_config::load_server_config(None)?;
let server_config = arc_config::server::load_server_config(None)?;
let cli_config = cli_config::load_cli_config(None)?;
args.verbose = args.verbose || cli_config.verbose;
let github_app = build_github_app_credentials(&server_config);
@ -351,6 +361,7 @@ async fn main_inner() -> Result<()> {
};
arc_llm::cli::run_models(command, server).await?
}
#[cfg(feature = "server")]
Command::Serve(args) => {
let styles: &'static arc_util::terminal::Styles =
Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr()));
@ -370,7 +381,7 @@ async fn main_inner() -> Result<()> {
}
Command::Pr { command } => match command {
PrCommand::Create(args) => {
let server_config = arc_api::server_config::load_server_config(None)?;
let server_config = arc_config::server::load_server_config(None)?;
let github_app = build_github_app_credentials(&server_config);
arc_workflows::cli::pr::pr_create_command(args, github_app).await?;
}

View file

@ -591,7 +591,7 @@ mod tests {
#[test]
fn config_toml_roundtrips() {
let toml_str = format_config_toml("brynary");
let config: arc_api::server_config::ServerConfig =
let config: arc_config::server::ServerConfig =
toml::from_str(&toml_str).expect("config should parse");
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary"]);
}
@ -599,12 +599,12 @@ mod tests {
#[test]
fn config_toml_has_auth_strategies() {
let toml_str = format_config_toml("alice");
let config: arc_api::server_config::ServerConfig = toml::from_str(&toml_str).unwrap();
let config: arc_config::server::ServerConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
config.api.authentication_strategies,
vec![
arc_api::server_config::ApiAuthStrategy::Jwt,
arc_api::server_config::ApiAuthStrategy::Mtls,
arc_config::server::ApiAuthStrategy::Jwt,
arc_config::server::ApiAuthStrategy::Mtls,
]
);
}
@ -612,7 +612,7 @@ mod tests {
#[test]
fn config_toml_has_tls_paths() {
let toml_str = format_config_toml("bob");
let config: arc_api::server_config::ServerConfig = toml::from_str(&toml_str).unwrap();
let config: arc_config::server::ServerConfig = toml::from_str(&toml_str).unwrap();
let tls = config.api.tls.expect("tls should be set");
assert_eq!(tls.cert, PathBuf::from("~/.arc/certs/server.crt"));
assert_eq!(tls.key, PathBuf::from("~/.arc/certs/server.key"));

View file

@ -555,6 +555,7 @@ fn validate_invalid() {
// == Arc: serve =========================================================
#[test]
#[cfg(feature = "server")]
fn serve_help() {
arc()
.args(["serve", "--help"])

View file

@ -0,0 +1,22 @@
[package]
name = "arc-config"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Centralized configuration types for Arc"
[lib]
doctest = false
[dependencies]
anyhow.workspace = true
arc-agent = { path = "../arc-agent" }
arc-mcp = { path = "../arc-mcp" }
arc-workflows = { path = "../arc-workflows" }
dirs.workspace = true
serde.workspace = true
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,398 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use arc_agent::cli::{OutputFormat, PermissionLevel};
use arc_mcp::config::{McpServerConfig, McpTransport};
use arc_workflows::cli::run_config::PullRequestConfig;
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 ExecDefaults {
pub provider: Option<String>,
pub model: Option<String>,
pub permissions: Option<PermissionLevel>,
pub output_format: Option<OutputFormat>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct LlmDefaults {
pub model: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliGitConfig {
#[serde(default)]
pub author: crate::server::GitAuthorConfig,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct McpServerEntry {
#[serde(flatten)]
pub transport: McpTransport,
#[serde(default = "arc_mcp::config::default_startup_timeout_secs")]
pub startup_timeout_secs: u64,
#[serde(default = "arc_mcp::config::default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
}
impl McpServerEntry {
pub fn into_config(self, name: String) -> McpServerConfig {
McpServerConfig {
name,
transport: self.transport,
startup_timeout_secs: self.startup_timeout_secs,
tool_timeout_secs: self.tool_timeout_secs,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliConfig {
pub mode: Option<ExecutionMode>,
pub server: Option<ServerDefaults>,
pub exec: Option<ExecDefaults>,
pub llm: Option<LlmDefaults>,
pub git: Option<CliGitConfig>,
#[serde(default)]
pub verbose: bool,
#[serde(default)]
pub log: crate::server::LogConfig,
pub pull_request: Option<PullRequestConfig>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
}
/// 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> {
if let Some(explicit) = path {
debug!(path = %explicit.display(), "Loading CLI config from explicit path");
let contents = std::fs::read_to_string(explicit)?;
return Ok(toml::from_str(&contents)?);
}
let Some(home) = dirs::home_dir() else {
debug!("No home directory found, using default CLI config");
return Ok(CliConfig::default());
};
let default_path = home.join(".arc").join("cli.toml");
debug!(path = %default_path.display(), "Loading CLI config");
match std::fs::read_to_string(&default_path) {
Ok(contents) => Ok(toml::from_str(&contents)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CliConfig::default()),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_config_defaults() {
let config: CliConfig = toml::from_str("").unwrap();
assert_eq!(config, CliConfig::default());
}
#[test]
fn parse_full_config() {
let toml = r#"
[exec]
provider = "anthropic"
model = "claude-opus-4-6"
permissions = "read-write"
output_format = "text"
[llm]
model = "claude-sonnet-4-5"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let exec = config.exec.unwrap();
assert_eq!(exec.provider.as_deref(), Some("anthropic"));
assert_eq!(exec.model.as_deref(), Some("claude-opus-4-6"));
assert_eq!(exec.permissions, Some(PermissionLevel::ReadWrite));
assert_eq!(exec.output_format, Some(OutputFormat::Text));
let llm = config.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5"));
}
#[test]
fn parse_partial_exec_config() {
let toml = r#"
[exec]
provider = "openai"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let exec = config.exec.unwrap();
assert_eq!(exec.provider.as_deref(), Some("openai"));
assert_eq!(exec.model, None);
assert_eq!(exec.permissions, None);
assert_eq!(exec.output_format, None);
assert_eq!(config.llm, None);
}
#[test]
fn load_cli_config_from_explicit_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.toml");
std::fs::write(
&path,
r#"
[exec]
provider = "gemini"
model = "gemini-pro"
"#,
)
.unwrap();
let config = load_cli_config(Some(&path)).unwrap();
let exec = config.exec.unwrap();
assert_eq!(exec.provider.as_deref(), Some("gemini"));
assert_eq!(exec.model.as_deref(), Some("gemini-pro"));
}
#[test]
fn load_cli_config_explicit_path_missing_is_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.toml");
let result = load_cli_config(Some(&path));
assert!(result.is_err());
}
#[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);
}
#[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);
}
#[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"));
}
#[test]
fn parse_git_author_config() {
let toml = r#"
[git.author]
name = "my-arc"
email = "me@local"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let git = config.git.unwrap();
assert_eq!(git.author.name.as_deref(), Some("my-arc"));
assert_eq!(git.author.email.as_deref(), Some("me@local"));
}
#[test]
fn parse_git_author_absent() {
let config: CliConfig = toml::from_str("").unwrap();
assert_eq!(config.git, None);
}
#[test]
fn parse_verbose_true() {
let config: CliConfig = toml::from_str("verbose = true").unwrap();
assert!(config.verbose);
}
#[test]
fn parse_log_level() {
let toml = "[log]\nlevel = \"debug\"";
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.log.level.as_deref(), Some("debug"));
}
#[test]
fn parse_pull_request_enabled() {
let toml = r#"
[pull_request]
enabled = true
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let pr = config.pull_request.unwrap();
assert!(pr.enabled);
}
#[test]
fn parse_pull_request_absent() {
let config: CliConfig = toml::from_str("").unwrap();
assert_eq!(config.pull_request, None);
}
#[test]
fn parse_mcp_stdio_server_with_env_and_timeouts() {
let toml = r#"
[mcp_servers.filesystem]
type = "stdio"
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
startup_timeout_secs = 15
tool_timeout_secs = 90
[mcp_servers.filesystem.env]
NODE_ENV = "production"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.mcp_servers.len(), 1);
let entry = &config.mcp_servers["filesystem"];
assert_eq!(entry.startup_timeout_secs, 15);
assert_eq!(entry.tool_timeout_secs, 90);
match &entry.transport {
McpTransport::Stdio { command, env } => {
assert_eq!(
command,
&[
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/workspace"
]
);
assert_eq!(env.get("NODE_ENV").unwrap(), "production");
}
_ => panic!("expected Stdio transport"),
}
}
#[test]
fn parse_mcp_http_server_with_headers() {
let toml = r#"
[mcp_servers.sentry]
type = "http"
url = "https://mcp.sentry.dev/mcp"
[mcp_servers.sentry.headers]
Authorization = "Bearer sk-xxx"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.mcp_servers.len(), 1);
let entry = &config.mcp_servers["sentry"];
match &entry.transport {
McpTransport::Http { url, headers } => {
assert_eq!(url, "https://mcp.sentry.dev/mcp");
assert_eq!(headers.get("Authorization").unwrap(), "Bearer sk-xxx");
}
_ => panic!("expected Http transport"),
}
}
#[test]
fn parse_mcp_empty_backward_compat() {
let config: CliConfig = toml::from_str("").unwrap();
assert!(config.mcp_servers.is_empty());
}
#[test]
fn parse_mcp_both_transports() {
let toml = r#"
[mcp_servers.local]
type = "stdio"
command = ["python3", "server.py"]
[mcp_servers.remote]
type = "http"
url = "https://mcp.example.com"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
assert_eq!(config.mcp_servers.len(), 2);
assert!(matches!(
config.mcp_servers["local"].transport,
McpTransport::Stdio { .. }
));
assert!(matches!(
config.mcp_servers["remote"].transport,
McpTransport::Http { .. }
));
}
#[test]
fn parse_mcp_defaults_applied_when_timeouts_omitted() {
let toml = r#"
[mcp_servers.minimal]
type = "stdio"
command = ["echo"]
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let entry = &config.mcp_servers["minimal"];
assert_eq!(entry.startup_timeout_secs, 10);
assert_eq!(entry.tool_timeout_secs, 60);
}
#[test]
fn mcp_server_entry_into_config() {
let entry = McpServerEntry {
transport: McpTransport::Stdio {
command: vec!["node".into(), "server.js".into()],
env: HashMap::new(),
},
startup_timeout_secs: 15,
tool_timeout_secs: 90,
};
let config = entry.into_config("my-server".into());
assert_eq!(config.name, "my-server");
assert_eq!(config.startup_timeout_secs, 15);
assert_eq!(config.tool_timeout_secs, 90);
}
}

View file

@ -0,0 +1,32 @@
pub mod cli;
pub mod project;
pub mod server;
use std::path::{Path, PathBuf};
/// Expand `~/` prefix to the user's home directory.
pub fn expand_tilde(path: &Path) -> PathBuf {
if let Ok(rest) = path.strip_prefix("~") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
path.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_with_home_prefix() {
let result = expand_tilde(Path::new("~/foo/bar"));
assert!(result != Path::new("~/foo/bar"));
assert!(result.ends_with("foo/bar"));
}
#[test]
fn expand_tilde_without_prefix() {
assert_eq!(expand_tilde(Path::new("/abs/path")), Path::new("/abs/path"));
}
}

View file

@ -0,0 +1 @@
pub use arc_workflows::cli::project_config::*;