From 11414c15b29d2bf33bc30eb82322dd7a87baaf6e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 9 Mar 2026 23:50:41 -0400 Subject: [PATCH] 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 --- Cargo.lock | 17 + lib/crates/arc-api/Cargo.toml | 1 + lib/crates/arc-api/src/demo/mod.rs | 2 +- lib/crates/arc-api/src/jwt_auth.rs | 4 +- lib/crates/arc-api/src/lib.rs | 4 +- lib/crates/arc-api/src/serve.rs | 9 +- lib/crates/arc-api/src/tls.rs | 15 +- lib/crates/arc-cli/Cargo.toml | 7 +- lib/crates/arc-cli/src/cli_config.rs | 406 +----------------- lib/crates/arc-cli/src/doctor.rs | 6 +- lib/crates/arc-cli/src/main.rs | 29 +- lib/crates/arc-cli/src/setup.rs | 10 +- lib/crates/arc-cli/tests/cli.rs | 1 + lib/crates/arc-config/Cargo.toml | 22 + lib/crates/arc-config/src/cli.rs | 398 +++++++++++++++++ lib/crates/arc-config/src/lib.rs | 32 ++ lib/crates/arc-config/src/project.rs | 1 + .../src/server.rs} | 0 18 files changed, 527 insertions(+), 437 deletions(-) create mode 100644 lib/crates/arc-config/Cargo.toml create mode 100644 lib/crates/arc-config/src/cli.rs create mode 100644 lib/crates/arc-config/src/lib.rs create mode 100644 lib/crates/arc-config/src/project.rs rename lib/crates/{arc-api/src/server_config.rs => arc-config/src/server.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index 99a5c46e4..0f1711a7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/lib/crates/arc-api/Cargo.toml b/lib/crates/arc-api/Cargo.toml index b1bfc6159..17dc93361 100644 --- a/lib/crates/arc-api/Cargo.toml +++ b/lib/crates/arc-api/Cargo.toml @@ -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" } diff --git a/lib/crates/arc-api/src/demo/mod.rs b/lib/crates/arc-api/src/demo/mod.rs index 50d936787..a682e1c7e 100644 --- a/lib/crates/arc-api/src/demo/mod.rs +++ b/lib/crates/arc-api/src/demo/mod.rs @@ -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 { diff --git a/lib/crates/arc-api/src/jwt_auth.rs b/lib/crates/arc-api/src/jwt_auth.rs index 99fc3984c..a9777f031 100644 --- a/lib/crates/arc-api/src/jwt_auth.rs +++ b/lib/crates/arc-api/src/jwt_auth.rs @@ -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, ) -> 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"); diff --git a/lib/crates/arc-api/src/lib.rs b/lib/crates/arc-api/src/lib.rs index 978da93e0..928cac49f 100644 --- a/lib/crates/arc-api/src/lib.rs +++ b/lib/crates/arc-api/src/lib.rs @@ -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; diff --git a/lib/crates/arc-api/src/serve.rs b/lib/crates/arc-api/src/serve.rs index 80c2f5319..d694f2660 100644 --- a/lib/crates/arc-api/src/serve.rs +++ b/lib/crates/arc-api/src/serve.rs @@ -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"); diff --git a/lib/crates/arc-api/src/tls.rs b/lib/crates/arc-api/src/tls.rs index 1ef849da5..3f6708328 100644 --- a/lib/crates/arc-api/src/tls.rs +++ b/lib/crates/arc-api/src/tls.rs @@ -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> { let path = expand_tilde(path); diff --git a/lib/crates/arc-cli/Cargo.toml b/lib/crates/arc-cli/Cargo.toml index 00d47b62d..4de8fb0dc 100644 --- a/lib/crates/arc-cli/Cargo.toml +++ b/lib/crates/arc-cli/Cargo.toml @@ -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 diff --git a/lib/crates/arc-cli/src/cli_config.rs b/lib/crates/arc-cli/src/cli_config.rs index ec2055147..82e70081b 100644 --- a/lib/crates/arc-cli/src/cli_config.rs +++ b/lib/crates/arc-cli/src/cli_config.rs @@ -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, - pub tls: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] -pub struct ExecDefaults { - pub provider: Option, - pub model: Option, - pub permissions: Option, - pub output_format: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] -pub struct LlmDefaults { - pub model: Option, -} - -#[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, - pub server: Option, - pub exec: Option, - pub llm: Option, - pub git: Option, - #[serde(default)] - pub verbose: bool, - #[serde(default)] - pub log: arc_api::server_config::LogConfig, - pub pull_request: Option, - #[serde(default)] - pub mcp_servers: HashMap, -} - #[derive(Debug, PartialEq)] pub struct ResolvedMode { pub mode: ExecutionMode, @@ -123,9 +41,9 @@ pub fn build_server_client(tls: Option<&ClientTlsConfig>) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result { - 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); - } } diff --git a/lib/crates/arc-cli/src/doctor.rs b/lib/crates/arc-cli/src/doctor.rs index 2f31dc4ba..831a45da5 100644 --- a/lib/crates/arc-cli/src/doctor.rs +++ b/lib/crates/arc-cli/src/doctor.rs @@ -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 { - 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())) }; diff --git a/lib/crates/arc-cli/src/main.rs b/lib/crates/arc-cli/src/main.rs index 0bde068fa..f11363209 100644 --- a/lib/crates/arc-cli/src/main.rs +++ b/lib/crates/arc-cli/src/main.rs @@ -63,6 +63,7 @@ enum Command { command: Option, }, /// 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 { 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?; } diff --git a/lib/crates/arc-cli/src/setup.rs b/lib/crates/arc-cli/src/setup.rs index 4ef392e8b..d4d93f56d 100644 --- a/lib/crates/arc-cli/src/setup.rs +++ b/lib/crates/arc-cli/src/setup.rs @@ -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")); diff --git a/lib/crates/arc-cli/tests/cli.rs b/lib/crates/arc-cli/tests/cli.rs index 4e010e310..29c5b7698 100644 --- a/lib/crates/arc-cli/tests/cli.rs +++ b/lib/crates/arc-cli/tests/cli.rs @@ -555,6 +555,7 @@ fn validate_invalid() { // == Arc: serve ========================================================= #[test] +#[cfg(feature = "server")] fn serve_help() { arc() .args(["serve", "--help"]) diff --git a/lib/crates/arc-config/Cargo.toml b/lib/crates/arc-config/Cargo.toml new file mode 100644 index 000000000..30cbd46a3 --- /dev/null +++ b/lib/crates/arc-config/Cargo.toml @@ -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" diff --git a/lib/crates/arc-config/src/cli.rs b/lib/crates/arc-config/src/cli.rs new file mode 100644 index 000000000..1f13984c5 --- /dev/null +++ b/lib/crates/arc-config/src/cli.rs @@ -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, + pub tls: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct ExecDefaults { + pub provider: Option, + pub model: Option, + pub permissions: Option, + pub output_format: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct LlmDefaults { + pub model: Option, +} + +#[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, + pub server: Option, + pub exec: Option, + pub llm: Option, + pub git: Option, + #[serde(default)] + pub verbose: bool, + #[serde(default)] + pub log: crate::server::LogConfig, + pub pull_request: Option, + #[serde(default)] + pub mcp_servers: HashMap, +} + +/// 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 { + 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); + } +} diff --git a/lib/crates/arc-config/src/lib.rs b/lib/crates/arc-config/src/lib.rs new file mode 100644 index 000000000..67803db70 --- /dev/null +++ b/lib/crates/arc-config/src/lib.rs @@ -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")); + } +} diff --git a/lib/crates/arc-config/src/project.rs b/lib/crates/arc-config/src/project.rs new file mode 100644 index 000000000..a5754bf6b --- /dev/null +++ b/lib/crates/arc-config/src/project.rs @@ -0,0 +1 @@ +pub use arc_workflows::cli::project_config::*; diff --git a/lib/crates/arc-api/src/server_config.rs b/lib/crates/arc-config/src/server.rs similarity index 100% rename from lib/crates/arc-api/src/server_config.rs rename to lib/crates/arc-config/src/server.rs