config.rs 28 KB

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