mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 23:54:50 +08:00
Add typed runtime-facing MCP and OAuth configuration models on top of the existing merged settings loader so later parity work can consume validated structures instead of ad hoc JSON traversal. This keeps the first slice bounded to parsing, precedence, exports, and tests. While validating the slice under the repo's required clippy gate, I also fixed a handful of pre-existing clippy failures in runtime file operations so the requested verification command can pass for this commit. Constraint: Must keep scope to parity-unblocking primitives, not full MCP or OAuth flow execution Constraint: cargo clippy --all-targets is a required verification gate for this repo Rejected: Add a new integrations crate first | too much boundary churn for the first landing slice Rejected: Leave existing clippy failures untouched | would block the required verification command for this commit Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep future MCP/OAuth additions layered on these typed config surfaces before introducing transport orchestration Tested: cargo fmt --all; cargo test -p runtime; cargo clippy -p runtime --all-targets -- -D warnings Not-tested: workspace-wide clippy/test beyond the runtime crate; live MCP or OAuth network flows
796 lines
24 KiB
Rust
796 lines
24 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::fmt::{Display, Formatter};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::json::JsonValue;
|
|
|
|
pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum ConfigSource {
|
|
User,
|
|
Project,
|
|
Local,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ConfigEntry {
|
|
pub source: ConfigSource,
|
|
pub path: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RuntimeConfig {
|
|
merged: BTreeMap<String, JsonValue>,
|
|
loaded_entries: Vec<ConfigEntry>,
|
|
feature_config: RuntimeFeatureConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct RuntimeFeatureConfig {
|
|
mcp: McpConfigCollection,
|
|
oauth: Option<OAuthConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct McpConfigCollection {
|
|
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ScopedMcpServerConfig {
|
|
pub scope: ConfigSource,
|
|
pub config: McpServerConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum McpTransport {
|
|
Stdio,
|
|
Sse,
|
|
Http,
|
|
Ws,
|
|
Sdk,
|
|
ClaudeAiProxy,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum McpServerConfig {
|
|
Stdio(McpStdioServerConfig),
|
|
Sse(McpRemoteServerConfig),
|
|
Http(McpRemoteServerConfig),
|
|
Ws(McpWebSocketServerConfig),
|
|
Sdk(McpSdkServerConfig),
|
|
ClaudeAiProxy(McpClaudeAiProxyServerConfig),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpStdioServerConfig {
|
|
pub command: String,
|
|
pub args: Vec<String>,
|
|
pub env: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpRemoteServerConfig {
|
|
pub url: String,
|
|
pub headers: BTreeMap<String, String>,
|
|
pub headers_helper: Option<String>,
|
|
pub oauth: Option<McpOAuthConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpWebSocketServerConfig {
|
|
pub url: String,
|
|
pub headers: BTreeMap<String, String>,
|
|
pub headers_helper: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpSdkServerConfig {
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpClaudeAiProxyServerConfig {
|
|
pub url: String,
|
|
pub id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpOAuthConfig {
|
|
pub client_id: Option<String>,
|
|
pub callback_port: Option<u16>,
|
|
pub auth_server_metadata_url: Option<String>,
|
|
pub xaa: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct OAuthConfig {
|
|
pub client_id: String,
|
|
pub authorize_url: String,
|
|
pub token_url: String,
|
|
pub callback_port: Option<u16>,
|
|
pub manual_redirect_url: Option<String>,
|
|
pub scopes: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ConfigError {
|
|
Io(std::io::Error),
|
|
Parse(String),
|
|
}
|
|
|
|
impl Display for ConfigError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Io(error) => write!(f, "{error}"),
|
|
Self::Parse(error) => write!(f, "{error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ConfigError {}
|
|
|
|
impl From<std::io::Error> for ConfigError {
|
|
fn from(value: std::io::Error) -> Self {
|
|
Self::Io(value)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ConfigLoader {
|
|
cwd: PathBuf,
|
|
config_home: PathBuf,
|
|
}
|
|
|
|
impl ConfigLoader {
|
|
#[must_use]
|
|
pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
|
|
Self {
|
|
cwd: cwd.into(),
|
|
config_home: config_home.into(),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
|
|
let cwd = cwd.into();
|
|
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
|
|
.unwrap_or_else(|| PathBuf::from(".claude"));
|
|
Self { cwd, config_home }
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn discover(&self) -> Vec<ConfigEntry> {
|
|
vec![
|
|
ConfigEntry {
|
|
source: ConfigSource::User,
|
|
path: self.config_home.join("settings.json"),
|
|
},
|
|
ConfigEntry {
|
|
source: ConfigSource::Project,
|
|
path: self.cwd.join(".claude").join("settings.json"),
|
|
},
|
|
ConfigEntry {
|
|
source: ConfigSource::Local,
|
|
path: self.cwd.join(".claude").join("settings.local.json"),
|
|
},
|
|
]
|
|
}
|
|
|
|
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
|
let mut merged = BTreeMap::new();
|
|
let mut loaded_entries = Vec::new();
|
|
let mut mcp_servers = BTreeMap::new();
|
|
|
|
for entry in self.discover() {
|
|
let Some(value) = read_optional_json_object(&entry.path)? else {
|
|
continue;
|
|
};
|
|
merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
|
|
deep_merge_objects(&mut merged, &value);
|
|
loaded_entries.push(entry);
|
|
}
|
|
|
|
let feature_config = RuntimeFeatureConfig {
|
|
mcp: McpConfigCollection {
|
|
servers: mcp_servers,
|
|
},
|
|
oauth: parse_optional_oauth_config(
|
|
&JsonValue::Object(merged.clone()),
|
|
"merged settings.oauth",
|
|
)?,
|
|
};
|
|
|
|
Ok(RuntimeConfig {
|
|
merged,
|
|
loaded_entries,
|
|
feature_config,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl RuntimeConfig {
|
|
#[must_use]
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
merged: BTreeMap::new(),
|
|
loaded_entries: Vec::new(),
|
|
feature_config: RuntimeFeatureConfig::default(),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
|
|
&self.merged
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn loaded_entries(&self) -> &[ConfigEntry] {
|
|
&self.loaded_entries
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get(&self, key: &str) -> Option<&JsonValue> {
|
|
self.merged.get(key)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn as_json(&self) -> JsonValue {
|
|
JsonValue::Object(self.merged.clone())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn feature_config(&self) -> &RuntimeFeatureConfig {
|
|
&self.feature_config
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn mcp(&self) -> &McpConfigCollection {
|
|
&self.feature_config.mcp
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
self.feature_config.oauth.as_ref()
|
|
}
|
|
}
|
|
|
|
impl RuntimeFeatureConfig {
|
|
#[must_use]
|
|
pub fn mcp(&self) -> &McpConfigCollection {
|
|
&self.mcp
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
self.oauth.as_ref()
|
|
}
|
|
}
|
|
|
|
impl McpConfigCollection {
|
|
#[must_use]
|
|
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
|
|
&self.servers
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
|
|
self.servers.get(name)
|
|
}
|
|
}
|
|
|
|
impl ScopedMcpServerConfig {
|
|
#[must_use]
|
|
pub fn transport(&self) -> McpTransport {
|
|
self.config.transport()
|
|
}
|
|
}
|
|
|
|
impl McpServerConfig {
|
|
#[must_use]
|
|
pub fn transport(&self) -> McpTransport {
|
|
match self {
|
|
Self::Stdio(_) => McpTransport::Stdio,
|
|
Self::Sse(_) => McpTransport::Sse,
|
|
Self::Http(_) => McpTransport::Http,
|
|
Self::Ws(_) => McpTransport::Ws,
|
|
Self::Sdk(_) => McpTransport::Sdk,
|
|
Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_optional_json_object(
|
|
path: &Path,
|
|
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
|
let contents = match fs::read_to_string(path) {
|
|
Ok(contents) => contents,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(error) => return Err(ConfigError::Io(error)),
|
|
};
|
|
|
|
if contents.trim().is_empty() {
|
|
return Ok(Some(BTreeMap::new()));
|
|
}
|
|
|
|
let parsed = JsonValue::parse(&contents)
|
|
.map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?;
|
|
let object = parsed.as_object().ok_or_else(|| {
|
|
ConfigError::Parse(format!(
|
|
"{}: top-level settings value must be a JSON object",
|
|
path.display()
|
|
))
|
|
})?;
|
|
Ok(Some(object.clone()))
|
|
}
|
|
|
|
fn merge_mcp_servers(
|
|
target: &mut BTreeMap<String, ScopedMcpServerConfig>,
|
|
source: ConfigSource,
|
|
root: &BTreeMap<String, JsonValue>,
|
|
path: &Path,
|
|
) -> Result<(), ConfigError> {
|
|
let Some(mcp_servers) = root.get("mcpServers") else {
|
|
return Ok(());
|
|
};
|
|
let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
|
|
for (name, value) in servers {
|
|
let parsed = parse_mcp_server_config(
|
|
name,
|
|
value,
|
|
&format!("{}: mcpServers.{name}", path.display()),
|
|
)?;
|
|
target.insert(
|
|
name.clone(),
|
|
ScopedMcpServerConfig {
|
|
scope: source,
|
|
config: parsed,
|
|
},
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_optional_oauth_config(
|
|
root: &JsonValue,
|
|
context: &str,
|
|
) -> Result<Option<OAuthConfig>, ConfigError> {
|
|
let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
|
|
return Ok(None);
|
|
};
|
|
let object = expect_object(oauth_value, context)?;
|
|
let client_id = expect_string(object, "clientId", context)?.to_string();
|
|
let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
|
|
let token_url = expect_string(object, "tokenUrl", context)?.to_string();
|
|
let callback_port = optional_u16(object, "callbackPort", context)?;
|
|
let manual_redirect_url =
|
|
optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
|
|
let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
|
|
Ok(Some(OAuthConfig {
|
|
client_id,
|
|
authorize_url,
|
|
token_url,
|
|
callback_port,
|
|
manual_redirect_url,
|
|
scopes,
|
|
}))
|
|
}
|
|
|
|
fn parse_mcp_server_config(
|
|
server_name: &str,
|
|
value: &JsonValue,
|
|
context: &str,
|
|
) -> Result<McpServerConfig, ConfigError> {
|
|
let object = expect_object(value, context)?;
|
|
let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
|
|
match server_type {
|
|
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
|
|
command: expect_string(object, "command", context)?.to_string(),
|
|
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
|
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
|
})),
|
|
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
|
object, context,
|
|
)?)),
|
|
"http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
|
|
object, context,
|
|
)?)),
|
|
"ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
|
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
|
})),
|
|
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
|
|
name: expect_string(object, "name", context)?.to_string(),
|
|
})),
|
|
"claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
|
|
McpClaudeAiProxyServerConfig {
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
id: expect_string(object, "id", context)?.to_string(),
|
|
},
|
|
)),
|
|
other => Err(ConfigError::Parse(format!(
|
|
"{context}: unsupported MCP server type for {server_name}: {other}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_mcp_remote_server_config(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
context: &str,
|
|
) -> Result<McpRemoteServerConfig, ConfigError> {
|
|
Ok(McpRemoteServerConfig {
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
|
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
|
oauth: parse_optional_mcp_oauth_config(object, context)?,
|
|
})
|
|
}
|
|
|
|
fn parse_optional_mcp_oauth_config(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
context: &str,
|
|
) -> Result<Option<McpOAuthConfig>, ConfigError> {
|
|
let Some(value) = object.get("oauth") else {
|
|
return Ok(None);
|
|
};
|
|
let oauth = expect_object(value, &format!("{context}.oauth"))?;
|
|
Ok(Some(McpOAuthConfig {
|
|
client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
|
|
callback_port: optional_u16(oauth, "callbackPort", context)?,
|
|
auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
|
|
.map(str::to_string),
|
|
xaa: optional_bool(oauth, "xaa", context)?,
|
|
}))
|
|
}
|
|
|
|
fn expect_object<'a>(
|
|
value: &'a JsonValue,
|
|
context: &str,
|
|
) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
|
|
value
|
|
.as_object()
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
|
|
}
|
|
|
|
fn expect_string<'a>(
|
|
object: &'a BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<&'a str, ConfigError> {
|
|
object
|
|
.get(key)
|
|
.and_then(JsonValue::as_str)
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
|
|
}
|
|
|
|
fn optional_string<'a>(
|
|
object: &'a BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<&'a str>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => value
|
|
.as_str()
|
|
.map(Some)
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_bool(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<bool>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => value
|
|
.as_bool()
|
|
.map(Some)
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_u16(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<u16>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => {
|
|
let Some(number) = value.as_i64() else {
|
|
return Err(ConfigError::Parse(format!(
|
|
"{context}: field {key} must be an integer"
|
|
)));
|
|
};
|
|
let number = u16::try_from(number).map_err(|_| {
|
|
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
|
})?;
|
|
Ok(Some(number))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_string_array(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<Vec<String>>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => {
|
|
let Some(array) = value.as_array() else {
|
|
return Err(ConfigError::Parse(format!(
|
|
"{context}: field {key} must be an array"
|
|
)));
|
|
};
|
|
array
|
|
.iter()
|
|
.map(|item| {
|
|
item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
|
|
ConfigError::Parse(format!(
|
|
"{context}: field {key} must contain only strings"
|
|
))
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map(Some)
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_string_map(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => {
|
|
let Some(map) = value.as_object() else {
|
|
return Err(ConfigError::Parse(format!(
|
|
"{context}: field {key} must be an object"
|
|
)));
|
|
};
|
|
map.iter()
|
|
.map(|(entry_key, entry_value)| {
|
|
entry_value
|
|
.as_str()
|
|
.map(|text| (entry_key.clone(), text.to_string()))
|
|
.ok_or_else(|| {
|
|
ConfigError::Parse(format!(
|
|
"{context}: field {key} must contain only string values"
|
|
))
|
|
})
|
|
})
|
|
.collect::<Result<BTreeMap<_, _>, _>>()
|
|
.map(Some)
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn deep_merge_objects(
|
|
target: &mut BTreeMap<String, JsonValue>,
|
|
source: &BTreeMap<String, JsonValue>,
|
|
) {
|
|
for (key, value) in source {
|
|
match (target.get_mut(key), value) {
|
|
(Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
|
|
deep_merge_objects(existing, incoming);
|
|
}
|
|
_ => {
|
|
target.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
|
};
|
|
use crate::json::JsonValue;
|
|
use std::fs;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn temp_dir() -> std::path::PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("time should be after epoch")
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!("runtime-config-{nanos}"))
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_object_settings_files() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
fs::write(home.join("settings.json"), "[]").expect("write bad settings");
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect_err("config should fail");
|
|
assert!(error
|
|
.to_string()
|
|
.contains("top-level settings value must be a JSON object"));
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
|
|
#[test]
|
|
fn loads_and_merges_claude_code_config_files_by_precedence() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
fs::write(
|
|
home.join("settings.json"),
|
|
r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#,
|
|
)
|
|
.expect("write user settings");
|
|
fs::write(
|
|
cwd.join(".claude").join("settings.json"),
|
|
r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#,
|
|
)
|
|
.expect("write project settings");
|
|
fs::write(
|
|
cwd.join(".claude").join("settings.local.json"),
|
|
r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect("config should load");
|
|
|
|
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
|
assert_eq!(loaded.loaded_entries().len(), 3);
|
|
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
|
assert_eq!(
|
|
loaded.get("model"),
|
|
Some(&JsonValue::String("opus".to_string()))
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.get("env")
|
|
.and_then(JsonValue::as_object)
|
|
.expect("env object")
|
|
.len(),
|
|
2
|
|
);
|
|
assert!(loaded
|
|
.get("hooks")
|
|
.and_then(JsonValue::as_object)
|
|
.expect("hooks object")
|
|
.contains_key("PreToolUse"));
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_typed_mcp_and_oauth_config() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
fs::write(
|
|
home.join("settings.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"stdio-server": {
|
|
"command": "uvx",
|
|
"args": ["mcp-server"],
|
|
"env": {"TOKEN": "secret"}
|
|
},
|
|
"remote-server": {
|
|
"type": "http",
|
|
"url": "https://example.test/mcp",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
"headersHelper": "helper.sh",
|
|
"oauth": {
|
|
"clientId": "mcp-client",
|
|
"callbackPort": 7777,
|
|
"authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
|
|
"xaa": true
|
|
}
|
|
}
|
|
},
|
|
"oauth": {
|
|
"clientId": "runtime-client",
|
|
"authorizeUrl": "https://console.test/oauth/authorize",
|
|
"tokenUrl": "https://console.test/oauth/token",
|
|
"callbackPort": 54545,
|
|
"manualRedirectUrl": "https://console.test/oauth/callback",
|
|
"scopes": ["org:read", "user:write"]
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write user settings");
|
|
fs::write(
|
|
cwd.join(".claude").join("settings.local.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"remote-server": {
|
|
"type": "ws",
|
|
"url": "wss://override.test/mcp",
|
|
"headers": {"X-Env": "local"}
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect("config should load");
|
|
|
|
let stdio_server = loaded
|
|
.mcp()
|
|
.get("stdio-server")
|
|
.expect("stdio server should exist");
|
|
assert_eq!(stdio_server.scope, ConfigSource::User);
|
|
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
|
|
|
let remote_server = loaded
|
|
.mcp()
|
|
.get("remote-server")
|
|
.expect("remote server should exist");
|
|
assert_eq!(remote_server.scope, ConfigSource::Local);
|
|
assert_eq!(remote_server.transport(), McpTransport::Ws);
|
|
match &remote_server.config {
|
|
McpServerConfig::Ws(config) => {
|
|
assert_eq!(config.url, "wss://override.test/mcp");
|
|
assert_eq!(
|
|
config.headers.get("X-Env").map(String::as_str),
|
|
Some("local")
|
|
);
|
|
}
|
|
other => panic!("expected ws config, got {other:?}"),
|
|
}
|
|
|
|
let oauth = loaded.oauth().expect("oauth config should exist");
|
|
assert_eq!(oauth.client_id, "runtime-client");
|
|
assert_eq!(oauth.callback_port, Some(54_545));
|
|
assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_mcp_server_shapes() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
fs::write(
|
|
home.join("settings.json"),
|
|
r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
|
|
)
|
|
.expect("write broken settings");
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect_err("config should fail");
|
|
assert!(error
|
|
.to_string()
|
|
.contains("mcpServers.broken: missing string field url"));
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
}
|