config.rs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. use std::collections::BTreeMap;
  2. use std::fmt::{Display, Formatter};
  3. use std::fs;
  4. use std::path::{Path, PathBuf};
  5. use crate::json::JsonValue;
  6. pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
  7. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  8. pub enum ConfigSource {
  9. User,
  10. Project,
  11. Local,
  12. }
  13. #[derive(Debug, Clone, PartialEq, Eq)]
  14. pub struct ConfigEntry {
  15. pub source: ConfigSource,
  16. pub path: PathBuf,
  17. }
  18. #[derive(Debug, Clone, PartialEq, Eq)]
  19. pub struct RuntimeConfig {
  20. merged: BTreeMap<String, JsonValue>,
  21. loaded_entries: Vec<ConfigEntry>,
  22. feature_config: RuntimeFeatureConfig,
  23. }
  24. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  25. pub struct RuntimeFeatureConfig {
  26. mcp: McpConfigCollection,
  27. oauth: Option<OAuthConfig>,
  28. }
  29. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  30. pub struct McpConfigCollection {
  31. servers: BTreeMap<String, ScopedMcpServerConfig>,
  32. }
  33. #[derive(Debug, Clone, PartialEq, Eq)]
  34. pub struct ScopedMcpServerConfig {
  35. pub scope: ConfigSource,
  36. pub config: McpServerConfig,
  37. }
  38. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  39. pub enum McpTransport {
  40. Stdio,
  41. Sse,
  42. Http,
  43. Ws,
  44. Sdk,
  45. ClaudeAiProxy,
  46. }
  47. #[derive(Debug, Clone, PartialEq, Eq)]
  48. pub enum McpServerConfig {
  49. Stdio(McpStdioServerConfig),
  50. Sse(McpRemoteServerConfig),
  51. Http(McpRemoteServerConfig),
  52. Ws(McpWebSocketServerConfig),
  53. Sdk(McpSdkServerConfig),
  54. ClaudeAiProxy(McpClaudeAiProxyServerConfig),
  55. }
  56. #[derive(Debug, Clone, PartialEq, Eq)]
  57. pub struct McpStdioServerConfig {
  58. pub command: String,
  59. pub args: Vec<String>,
  60. pub env: BTreeMap<String, String>,
  61. }
  62. #[derive(Debug, Clone, PartialEq, Eq)]
  63. pub struct McpRemoteServerConfig {
  64. pub url: String,
  65. pub headers: BTreeMap<String, String>,
  66. pub headers_helper: Option<String>,
  67. pub oauth: Option<McpOAuthConfig>,
  68. }
  69. #[derive(Debug, Clone, PartialEq, Eq)]
  70. pub struct McpWebSocketServerConfig {
  71. pub url: String,
  72. pub headers: BTreeMap<String, String>,
  73. pub headers_helper: Option<String>,
  74. }
  75. #[derive(Debug, Clone, PartialEq, Eq)]
  76. pub struct McpSdkServerConfig {
  77. pub name: String,
  78. }
  79. #[derive(Debug, Clone, PartialEq, Eq)]
  80. pub struct McpClaudeAiProxyServerConfig {
  81. pub url: String,
  82. pub id: String,
  83. }
  84. #[derive(Debug, Clone, PartialEq, Eq)]
  85. pub struct McpOAuthConfig {
  86. pub client_id: Option<String>,
  87. pub callback_port: Option<u16>,
  88. pub auth_server_metadata_url: Option<String>,
  89. pub xaa: Option<bool>,
  90. }
  91. #[derive(Debug, Clone, PartialEq, Eq)]
  92. pub struct OAuthConfig {
  93. pub client_id: String,
  94. pub authorize_url: String,
  95. pub token_url: String,
  96. pub callback_port: Option<u16>,
  97. pub manual_redirect_url: Option<String>,
  98. pub scopes: Vec<String>,
  99. }
  100. #[derive(Debug)]
  101. pub enum ConfigError {
  102. Io(std::io::Error),
  103. Parse(String),
  104. }
  105. impl Display for ConfigError {
  106. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  107. match self {
  108. Self::Io(error) => write!(f, "{error}"),
  109. Self::Parse(error) => write!(f, "{error}"),
  110. }
  111. }
  112. }
  113. impl std::error::Error for ConfigError {}
  114. impl From<std::io::Error> for ConfigError {
  115. fn from(value: std::io::Error) -> Self {
  116. Self::Io(value)
  117. }
  118. }
  119. #[derive(Debug, Clone, PartialEq, Eq)]
  120. pub struct ConfigLoader {
  121. cwd: PathBuf,
  122. config_home: PathBuf,
  123. }
  124. impl ConfigLoader {
  125. #[must_use]
  126. pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
  127. Self {
  128. cwd: cwd.into(),
  129. config_home: config_home.into(),
  130. }
  131. }
  132. #[must_use]
  133. pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
  134. let cwd = cwd.into();
  135. let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
  136. .map(PathBuf::from)
  137. .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
  138. .unwrap_or_else(|| PathBuf::from(".claude"));
  139. Self { cwd, config_home }
  140. }
  141. #[must_use]
  142. pub fn discover(&self) -> Vec<ConfigEntry> {
  143. vec![
  144. ConfigEntry {
  145. source: ConfigSource::User,
  146. path: self.config_home.join("settings.json"),
  147. },
  148. ConfigEntry {
  149. source: ConfigSource::Project,
  150. path: self.cwd.join(".claude").join("settings.json"),
  151. },
  152. ConfigEntry {
  153. source: ConfigSource::Local,
  154. path: self.cwd.join(".claude").join("settings.local.json"),
  155. },
  156. ]
  157. }
  158. pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
  159. let mut merged = BTreeMap::new();
  160. let mut loaded_entries = Vec::new();
  161. let mut mcp_servers = BTreeMap::new();
  162. for entry in self.discover() {
  163. let Some(value) = read_optional_json_object(&entry.path)? else {
  164. continue;
  165. };
  166. merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
  167. deep_merge_objects(&mut merged, &value);
  168. loaded_entries.push(entry);
  169. }
  170. let feature_config = RuntimeFeatureConfig {
  171. mcp: McpConfigCollection {
  172. servers: mcp_servers,
  173. },
  174. oauth: parse_optional_oauth_config(
  175. &JsonValue::Object(merged.clone()),
  176. "merged settings.oauth",
  177. )?,
  178. };
  179. Ok(RuntimeConfig {
  180. merged,
  181. loaded_entries,
  182. feature_config,
  183. })
  184. }
  185. }
  186. impl RuntimeConfig {
  187. #[must_use]
  188. pub fn empty() -> Self {
  189. Self {
  190. merged: BTreeMap::new(),
  191. loaded_entries: Vec::new(),
  192. feature_config: RuntimeFeatureConfig::default(),
  193. }
  194. }
  195. #[must_use]
  196. pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
  197. &self.merged
  198. }
  199. #[must_use]
  200. pub fn loaded_entries(&self) -> &[ConfigEntry] {
  201. &self.loaded_entries
  202. }
  203. #[must_use]
  204. pub fn get(&self, key: &str) -> Option<&JsonValue> {
  205. self.merged.get(key)
  206. }
  207. #[must_use]
  208. pub fn as_json(&self) -> JsonValue {
  209. JsonValue::Object(self.merged.clone())
  210. }
  211. #[must_use]
  212. pub fn feature_config(&self) -> &RuntimeFeatureConfig {
  213. &self.feature_config
  214. }
  215. #[must_use]
  216. pub fn mcp(&self) -> &McpConfigCollection {
  217. &self.feature_config.mcp
  218. }
  219. #[must_use]
  220. pub fn oauth(&self) -> Option<&OAuthConfig> {
  221. self.feature_config.oauth.as_ref()
  222. }
  223. }
  224. impl RuntimeFeatureConfig {
  225. #[must_use]
  226. pub fn mcp(&self) -> &McpConfigCollection {
  227. &self.mcp
  228. }
  229. #[must_use]
  230. pub fn oauth(&self) -> Option<&OAuthConfig> {
  231. self.oauth.as_ref()
  232. }
  233. }
  234. impl McpConfigCollection {
  235. #[must_use]
  236. pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
  237. &self.servers
  238. }
  239. #[must_use]
  240. pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
  241. self.servers.get(name)
  242. }
  243. }
  244. impl ScopedMcpServerConfig {
  245. #[must_use]
  246. pub fn transport(&self) -> McpTransport {
  247. self.config.transport()
  248. }
  249. }
  250. impl McpServerConfig {
  251. #[must_use]
  252. pub fn transport(&self) -> McpTransport {
  253. match self {
  254. Self::Stdio(_) => McpTransport::Stdio,
  255. Self::Sse(_) => McpTransport::Sse,
  256. Self::Http(_) => McpTransport::Http,
  257. Self::Ws(_) => McpTransport::Ws,
  258. Self::Sdk(_) => McpTransport::Sdk,
  259. Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
  260. }
  261. }
  262. }
  263. fn read_optional_json_object(
  264. path: &Path,
  265. ) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
  266. let contents = match fs::read_to_string(path) {
  267. Ok(contents) => contents,
  268. Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
  269. Err(error) => return Err(ConfigError::Io(error)),
  270. };
  271. if contents.trim().is_empty() {
  272. return Ok(Some(BTreeMap::new()));
  273. }
  274. let parsed = JsonValue::parse(&contents)
  275. .map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?;
  276. let object = parsed.as_object().ok_or_else(|| {
  277. ConfigError::Parse(format!(
  278. "{}: top-level settings value must be a JSON object",
  279. path.display()
  280. ))
  281. })?;
  282. Ok(Some(object.clone()))
  283. }
  284. fn merge_mcp_servers(
  285. target: &mut BTreeMap<String, ScopedMcpServerConfig>,
  286. source: ConfigSource,
  287. root: &BTreeMap<String, JsonValue>,
  288. path: &Path,
  289. ) -> Result<(), ConfigError> {
  290. let Some(mcp_servers) = root.get("mcpServers") else {
  291. return Ok(());
  292. };
  293. let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
  294. for (name, value) in servers {
  295. let parsed = parse_mcp_server_config(
  296. name,
  297. value,
  298. &format!("{}: mcpServers.{name}", path.display()),
  299. )?;
  300. target.insert(
  301. name.clone(),
  302. ScopedMcpServerConfig {
  303. scope: source,
  304. config: parsed,
  305. },
  306. );
  307. }
  308. Ok(())
  309. }
  310. fn parse_optional_oauth_config(
  311. root: &JsonValue,
  312. context: &str,
  313. ) -> Result<Option<OAuthConfig>, ConfigError> {
  314. let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
  315. return Ok(None);
  316. };
  317. let object = expect_object(oauth_value, context)?;
  318. let client_id = expect_string(object, "clientId", context)?.to_string();
  319. let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
  320. let token_url = expect_string(object, "tokenUrl", context)?.to_string();
  321. let callback_port = optional_u16(object, "callbackPort", context)?;
  322. let manual_redirect_url =
  323. optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
  324. let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
  325. Ok(Some(OAuthConfig {
  326. client_id,
  327. authorize_url,
  328. token_url,
  329. callback_port,
  330. manual_redirect_url,
  331. scopes,
  332. }))
  333. }
  334. fn parse_mcp_server_config(
  335. server_name: &str,
  336. value: &JsonValue,
  337. context: &str,
  338. ) -> Result<McpServerConfig, ConfigError> {
  339. let object = expect_object(value, context)?;
  340. let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
  341. match server_type {
  342. "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
  343. command: expect_string(object, "command", context)?.to_string(),
  344. args: optional_string_array(object, "args", context)?.unwrap_or_default(),
  345. env: optional_string_map(object, "env", context)?.unwrap_or_default(),
  346. })),
  347. "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
  348. object, context,
  349. )?)),
  350. "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
  351. object, context,
  352. )?)),
  353. "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
  354. url: expect_string(object, "url", context)?.to_string(),
  355. headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
  356. headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
  357. })),
  358. "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
  359. name: expect_string(object, "name", context)?.to_string(),
  360. })),
  361. "claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
  362. McpClaudeAiProxyServerConfig {
  363. url: expect_string(object, "url", context)?.to_string(),
  364. id: expect_string(object, "id", context)?.to_string(),
  365. },
  366. )),
  367. other => Err(ConfigError::Parse(format!(
  368. "{context}: unsupported MCP server type for {server_name}: {other}"
  369. ))),
  370. }
  371. }
  372. fn parse_mcp_remote_server_config(
  373. object: &BTreeMap<String, JsonValue>,
  374. context: &str,
  375. ) -> Result<McpRemoteServerConfig, ConfigError> {
  376. Ok(McpRemoteServerConfig {
  377. url: expect_string(object, "url", context)?.to_string(),
  378. headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
  379. headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
  380. oauth: parse_optional_mcp_oauth_config(object, context)?,
  381. })
  382. }
  383. fn parse_optional_mcp_oauth_config(
  384. object: &BTreeMap<String, JsonValue>,
  385. context: &str,
  386. ) -> Result<Option<McpOAuthConfig>, ConfigError> {
  387. let Some(value) = object.get("oauth") else {
  388. return Ok(None);
  389. };
  390. let oauth = expect_object(value, &format!("{context}.oauth"))?;
  391. Ok(Some(McpOAuthConfig {
  392. client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
  393. callback_port: optional_u16(oauth, "callbackPort", context)?,
  394. auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
  395. .map(str::to_string),
  396. xaa: optional_bool(oauth, "xaa", context)?,
  397. }))
  398. }
  399. fn expect_object<'a>(
  400. value: &'a JsonValue,
  401. context: &str,
  402. ) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
  403. value
  404. .as_object()
  405. .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
  406. }
  407. fn expect_string<'a>(
  408. object: &'a BTreeMap<String, JsonValue>,
  409. key: &str,
  410. context: &str,
  411. ) -> Result<&'a str, ConfigError> {
  412. object
  413. .get(key)
  414. .and_then(JsonValue::as_str)
  415. .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
  416. }
  417. fn optional_string<'a>(
  418. object: &'a BTreeMap<String, JsonValue>,
  419. key: &str,
  420. context: &str,
  421. ) -> Result<Option<&'a str>, ConfigError> {
  422. match object.get(key) {
  423. Some(value) => value
  424. .as_str()
  425. .map(Some)
  426. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
  427. None => Ok(None),
  428. }
  429. }
  430. fn optional_bool(
  431. object: &BTreeMap<String, JsonValue>,
  432. key: &str,
  433. context: &str,
  434. ) -> Result<Option<bool>, ConfigError> {
  435. match object.get(key) {
  436. Some(value) => value
  437. .as_bool()
  438. .map(Some)
  439. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
  440. None => Ok(None),
  441. }
  442. }
  443. fn optional_u16(
  444. object: &BTreeMap<String, JsonValue>,
  445. key: &str,
  446. context: &str,
  447. ) -> Result<Option<u16>, ConfigError> {
  448. match object.get(key) {
  449. Some(value) => {
  450. let Some(number) = value.as_i64() else {
  451. return Err(ConfigError::Parse(format!(
  452. "{context}: field {key} must be an integer"
  453. )));
  454. };
  455. let number = u16::try_from(number).map_err(|_| {
  456. ConfigError::Parse(format!("{context}: field {key} is out of range"))
  457. })?;
  458. Ok(Some(number))
  459. }
  460. None => Ok(None),
  461. }
  462. }
  463. fn optional_string_array(
  464. object: &BTreeMap<String, JsonValue>,
  465. key: &str,
  466. context: &str,
  467. ) -> Result<Option<Vec<String>>, ConfigError> {
  468. match object.get(key) {
  469. Some(value) => {
  470. let Some(array) = value.as_array() else {
  471. return Err(ConfigError::Parse(format!(
  472. "{context}: field {key} must be an array"
  473. )));
  474. };
  475. array
  476. .iter()
  477. .map(|item| {
  478. item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
  479. ConfigError::Parse(format!(
  480. "{context}: field {key} must contain only strings"
  481. ))
  482. })
  483. })
  484. .collect::<Result<Vec<_>, _>>()
  485. .map(Some)
  486. }
  487. None => Ok(None),
  488. }
  489. }
  490. fn optional_string_map(
  491. object: &BTreeMap<String, JsonValue>,
  492. key: &str,
  493. context: &str,
  494. ) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
  495. match object.get(key) {
  496. Some(value) => {
  497. let Some(map) = value.as_object() else {
  498. return Err(ConfigError::Parse(format!(
  499. "{context}: field {key} must be an object"
  500. )));
  501. };
  502. map.iter()
  503. .map(|(entry_key, entry_value)| {
  504. entry_value
  505. .as_str()
  506. .map(|text| (entry_key.clone(), text.to_string()))
  507. .ok_or_else(|| {
  508. ConfigError::Parse(format!(
  509. "{context}: field {key} must contain only string values"
  510. ))
  511. })
  512. })
  513. .collect::<Result<BTreeMap<_, _>, _>>()
  514. .map(Some)
  515. }
  516. None => Ok(None),
  517. }
  518. }
  519. fn deep_merge_objects(
  520. target: &mut BTreeMap<String, JsonValue>,
  521. source: &BTreeMap<String, JsonValue>,
  522. ) {
  523. for (key, value) in source {
  524. match (target.get_mut(key), value) {
  525. (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
  526. deep_merge_objects(existing, incoming);
  527. }
  528. _ => {
  529. target.insert(key.clone(), value.clone());
  530. }
  531. }
  532. }
  533. }
  534. #[cfg(test)]
  535. mod tests {
  536. use super::{
  537. ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
  538. };
  539. use crate::json::JsonValue;
  540. use std::fs;
  541. use std::time::{SystemTime, UNIX_EPOCH};
  542. fn temp_dir() -> std::path::PathBuf {
  543. let nanos = SystemTime::now()
  544. .duration_since(UNIX_EPOCH)
  545. .expect("time should be after epoch")
  546. .as_nanos();
  547. std::env::temp_dir().join(format!("runtime-config-{nanos}"))
  548. }
  549. #[test]
  550. fn rejects_non_object_settings_files() {
  551. let root = temp_dir();
  552. let cwd = root.join("project");
  553. let home = root.join("home").join(".claude");
  554. fs::create_dir_all(&home).expect("home config dir");
  555. fs::create_dir_all(&cwd).expect("project dir");
  556. fs::write(home.join("settings.json"), "[]").expect("write bad settings");
  557. let error = ConfigLoader::new(&cwd, &home)
  558. .load()
  559. .expect_err("config should fail");
  560. assert!(error
  561. .to_string()
  562. .contains("top-level settings value must be a JSON object"));
  563. fs::remove_dir_all(root).expect("cleanup temp dir");
  564. }
  565. #[test]
  566. fn loads_and_merges_claude_code_config_files_by_precedence() {
  567. let root = temp_dir();
  568. let cwd = root.join("project");
  569. let home = root.join("home").join(".claude");
  570. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  571. fs::create_dir_all(&home).expect("home config dir");
  572. fs::write(
  573. home.join("settings.json"),
  574. r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#,
  575. )
  576. .expect("write user settings");
  577. fs::write(
  578. cwd.join(".claude").join("settings.json"),
  579. r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#,
  580. )
  581. .expect("write project settings");
  582. fs::write(
  583. cwd.join(".claude").join("settings.local.json"),
  584. r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
  585. )
  586. .expect("write local settings");
  587. let loaded = ConfigLoader::new(&cwd, &home)
  588. .load()
  589. .expect("config should load");
  590. assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
  591. assert_eq!(loaded.loaded_entries().len(), 3);
  592. assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
  593. assert_eq!(
  594. loaded.get("model"),
  595. Some(&JsonValue::String("opus".to_string()))
  596. );
  597. assert_eq!(
  598. loaded
  599. .get("env")
  600. .and_then(JsonValue::as_object)
  601. .expect("env object")
  602. .len(),
  603. 2
  604. );
  605. assert!(loaded
  606. .get("hooks")
  607. .and_then(JsonValue::as_object)
  608. .expect("hooks object")
  609. .contains_key("PreToolUse"));
  610. fs::remove_dir_all(root).expect("cleanup temp dir");
  611. }
  612. #[test]
  613. fn parses_typed_mcp_and_oauth_config() {
  614. let root = temp_dir();
  615. let cwd = root.join("project");
  616. let home = root.join("home").join(".claude");
  617. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  618. fs::create_dir_all(&home).expect("home config dir");
  619. fs::write(
  620. home.join("settings.json"),
  621. r#"{
  622. "mcpServers": {
  623. "stdio-server": {
  624. "command": "uvx",
  625. "args": ["mcp-server"],
  626. "env": {"TOKEN": "secret"}
  627. },
  628. "remote-server": {
  629. "type": "http",
  630. "url": "https://example.test/mcp",
  631. "headers": {"Authorization": "Bearer token"},
  632. "headersHelper": "helper.sh",
  633. "oauth": {
  634. "clientId": "mcp-client",
  635. "callbackPort": 7777,
  636. "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
  637. "xaa": true
  638. }
  639. }
  640. },
  641. "oauth": {
  642. "clientId": "runtime-client",
  643. "authorizeUrl": "https://console.test/oauth/authorize",
  644. "tokenUrl": "https://console.test/oauth/token",
  645. "callbackPort": 54545,
  646. "manualRedirectUrl": "https://console.test/oauth/callback",
  647. "scopes": ["org:read", "user:write"]
  648. }
  649. }"#,
  650. )
  651. .expect("write user settings");
  652. fs::write(
  653. cwd.join(".claude").join("settings.local.json"),
  654. r#"{
  655. "mcpServers": {
  656. "remote-server": {
  657. "type": "ws",
  658. "url": "wss://override.test/mcp",
  659. "headers": {"X-Env": "local"}
  660. }
  661. }
  662. }"#,
  663. )
  664. .expect("write local settings");
  665. let loaded = ConfigLoader::new(&cwd, &home)
  666. .load()
  667. .expect("config should load");
  668. let stdio_server = loaded
  669. .mcp()
  670. .get("stdio-server")
  671. .expect("stdio server should exist");
  672. assert_eq!(stdio_server.scope, ConfigSource::User);
  673. assert_eq!(stdio_server.transport(), McpTransport::Stdio);
  674. let remote_server = loaded
  675. .mcp()
  676. .get("remote-server")
  677. .expect("remote server should exist");
  678. assert_eq!(remote_server.scope, ConfigSource::Local);
  679. assert_eq!(remote_server.transport(), McpTransport::Ws);
  680. match &remote_server.config {
  681. McpServerConfig::Ws(config) => {
  682. assert_eq!(config.url, "wss://override.test/mcp");
  683. assert_eq!(
  684. config.headers.get("X-Env").map(String::as_str),
  685. Some("local")
  686. );
  687. }
  688. other => panic!("expected ws config, got {other:?}"),
  689. }
  690. let oauth = loaded.oauth().expect("oauth config should exist");
  691. assert_eq!(oauth.client_id, "runtime-client");
  692. assert_eq!(oauth.callback_port, Some(54_545));
  693. assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
  694. fs::remove_dir_all(root).expect("cleanup temp dir");
  695. }
  696. #[test]
  697. fn rejects_invalid_mcp_server_shapes() {
  698. let root = temp_dir();
  699. let cwd = root.join("project");
  700. let home = root.join("home").join(".claude");
  701. fs::create_dir_all(&home).expect("home config dir");
  702. fs::create_dir_all(&cwd).expect("project dir");
  703. fs::write(
  704. home.join("settings.json"),
  705. r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
  706. )
  707. .expect("write broken settings");
  708. let error = ConfigLoader::new(&cwd, &home)
  709. .load()
  710. .expect_err("config should fail");
  711. assert!(error
  712. .to_string()
  713. .contains("mcpServers.broken: missing string field url"));
  714. fs::remove_dir_all(root).expect("cleanup temp dir");
  715. }
  716. }