mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
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:
parent
1adc580e43
commit
11414c15b2
18 changed files with 527 additions and 437 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -555,6 +555,7 @@ fn validate_invalid() {
|
|||
// == Arc: serve =========================================================
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "server")]
|
||||
fn serve_help() {
|
||||
arc()
|
||||
.args(["serve", "--help"])
|
||||
|
|
|
|||
22
lib/crates/arc-config/Cargo.toml
Normal file
22
lib/crates/arc-config/Cargo.toml
Normal 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"
|
||||
398
lib/crates/arc-config/src/cli.rs
Normal file
398
lib/crates/arc-config/src/cli.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
32
lib/crates/arc-config/src/lib.rs
Normal file
32
lib/crates/arc-config/src/lib.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
1
lib/crates/arc-config/src/project.rs
Normal file
1
lib/crates/arc-config/src/project.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub use arc_workflows::cli::project_config::*;
|
||||
Loading…
Add table
Reference in a new issue