|
@@ -14,6 +14,13 @@ pub enum ConfigSource {
|
|
|
Local,
|
|
Local,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
+pub enum ResolvedPermissionMode {
|
|
|
|
|
+ ReadOnly,
|
|
|
|
|
+ WorkspaceWrite,
|
|
|
|
|
+ DangerFullAccess,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
pub struct ConfigEntry {
|
|
pub struct ConfigEntry {
|
|
|
pub source: ConfigSource,
|
|
pub source: ConfigSource,
|
|
@@ -31,6 +38,8 @@ pub struct RuntimeConfig {
|
|
|
pub struct RuntimeFeatureConfig {
|
|
pub struct RuntimeFeatureConfig {
|
|
|
mcp: McpConfigCollection,
|
|
mcp: McpConfigCollection,
|
|
|
oauth: Option<OAuthConfig>,
|
|
oauth: Option<OAuthConfig>,
|
|
|
|
|
+ model: Option<String>,
|
|
|
|
|
+ permission_mode: Option<ResolvedPermissionMode>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
@@ -165,11 +174,23 @@ impl ConfigLoader {
|
|
|
|
|
|
|
|
#[must_use]
|
|
#[must_use]
|
|
|
pub fn discover(&self) -> Vec<ConfigEntry> {
|
|
pub fn discover(&self) -> Vec<ConfigEntry> {
|
|
|
|
|
+ let user_legacy_path = self.config_home.parent().map_or_else(
|
|
|
|
|
+ || PathBuf::from(".claude.json"),
|
|
|
|
|
+ |parent| parent.join(".claude.json"),
|
|
|
|
|
+ );
|
|
|
vec![
|
|
vec![
|
|
|
|
|
+ ConfigEntry {
|
|
|
|
|
+ source: ConfigSource::User,
|
|
|
|
|
+ path: user_legacy_path,
|
|
|
|
|
+ },
|
|
|
ConfigEntry {
|
|
ConfigEntry {
|
|
|
source: ConfigSource::User,
|
|
source: ConfigSource::User,
|
|
|
path: self.config_home.join("settings.json"),
|
|
path: self.config_home.join("settings.json"),
|
|
|
},
|
|
},
|
|
|
|
|
+ ConfigEntry {
|
|
|
|
|
+ source: ConfigSource::Project,
|
|
|
|
|
+ path: self.cwd.join(".claude.json"),
|
|
|
|
|
+ },
|
|
|
ConfigEntry {
|
|
ConfigEntry {
|
|
|
source: ConfigSource::Project,
|
|
source: ConfigSource::Project,
|
|
|
path: self.cwd.join(".claude").join("settings.json"),
|
|
path: self.cwd.join(".claude").join("settings.json"),
|
|
@@ -195,14 +216,15 @@ impl ConfigLoader {
|
|
|
loaded_entries.push(entry);
|
|
loaded_entries.push(entry);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ let merged_value = JsonValue::Object(merged.clone());
|
|
|
|
|
+
|
|
|
let feature_config = RuntimeFeatureConfig {
|
|
let feature_config = RuntimeFeatureConfig {
|
|
|
mcp: McpConfigCollection {
|
|
mcp: McpConfigCollection {
|
|
|
servers: mcp_servers,
|
|
servers: mcp_servers,
|
|
|
},
|
|
},
|
|
|
- oauth: parse_optional_oauth_config(
|
|
|
|
|
- &JsonValue::Object(merged.clone()),
|
|
|
|
|
- "merged settings.oauth",
|
|
|
|
|
- )?,
|
|
|
|
|
|
|
+ oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
|
|
|
|
+ model: parse_optional_model(&merged_value),
|
|
|
|
|
+ permission_mode: parse_optional_permission_mode(&merged_value)?,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
Ok(RuntimeConfig {
|
|
Ok(RuntimeConfig {
|
|
@@ -257,6 +279,16 @@ impl RuntimeConfig {
|
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
|
self.feature_config.oauth.as_ref()
|
|
self.feature_config.oauth.as_ref()
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ #[must_use]
|
|
|
|
|
+ pub fn model(&self) -> Option<&str> {
|
|
|
|
|
+ self.feature_config.model.as_deref()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[must_use]
|
|
|
|
|
+ pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
|
|
|
|
+ self.feature_config.permission_mode
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl RuntimeFeatureConfig {
|
|
impl RuntimeFeatureConfig {
|
|
@@ -269,6 +301,16 @@ impl RuntimeFeatureConfig {
|
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
|
self.oauth.as_ref()
|
|
self.oauth.as_ref()
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ #[must_use]
|
|
|
|
|
+ pub fn model(&self) -> Option<&str> {
|
|
|
|
|
+ self.model.as_deref()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[must_use]
|
|
|
|
|
+ pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
|
|
|
|
+ self.permission_mode
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl McpConfigCollection {
|
|
impl McpConfigCollection {
|
|
@@ -307,6 +349,7 @@ impl McpServerConfig {
|
|
|
fn read_optional_json_object(
|
|
fn read_optional_json_object(
|
|
|
path: &Path,
|
|
path: &Path,
|
|
|
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
|
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
|
|
|
|
+ let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
|
|
|
let contents = match fs::read_to_string(path) {
|
|
let contents = match fs::read_to_string(path) {
|
|
|
Ok(contents) => contents,
|
|
Ok(contents) => contents,
|
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
@@ -317,14 +360,20 @@ fn read_optional_json_object(
|
|
|
return Ok(Some(BTreeMap::new()));
|
|
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!(
|
|
|
|
|
|
|
+ let parsed = match JsonValue::parse(&contents) {
|
|
|
|
|
+ Ok(parsed) => parsed,
|
|
|
|
|
+ Err(error) if is_legacy_config => return Ok(None),
|
|
|
|
|
+ Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
|
|
|
|
|
+ };
|
|
|
|
|
+ let Some(object) = parsed.as_object() else {
|
|
|
|
|
+ if is_legacy_config {
|
|
|
|
|
+ return Ok(None);
|
|
|
|
|
+ }
|
|
|
|
|
+ return Err(ConfigError::Parse(format!(
|
|
|
"{}: top-level settings value must be a JSON object",
|
|
"{}: top-level settings value must be a JSON object",
|
|
|
path.display()
|
|
path.display()
|
|
|
- ))
|
|
|
|
|
- })?;
|
|
|
|
|
|
|
+ )));
|
|
|
|
|
+ };
|
|
|
Ok(Some(object.clone()))
|
|
Ok(Some(object.clone()))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -355,6 +404,47 @@ fn merge_mcp_servers(
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
|
|
|
|
+ root.as_object()
|
|
|
|
|
+ .and_then(|object| object.get("model"))
|
|
|
|
|
+ .and_then(JsonValue::as_str)
|
|
|
|
|
+ .map(ToOwned::to_owned)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_optional_permission_mode(
|
|
|
|
|
+ root: &JsonValue,
|
|
|
|
|
+) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
|
|
|
|
|
+ let Some(object) = root.as_object() else {
|
|
|
|
|
+ return Ok(None);
|
|
|
|
|
+ };
|
|
|
|
|
+ if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
|
|
|
|
|
+ return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
|
|
|
|
|
+ }
|
|
|
|
|
+ let Some(mode) = object
|
|
|
|
|
+ .get("permissions")
|
|
|
|
|
+ .and_then(JsonValue::as_object)
|
|
|
|
|
+ .and_then(|permissions| permissions.get("defaultMode"))
|
|
|
|
|
+ .and_then(JsonValue::as_str)
|
|
|
|
|
+ else {
|
|
|
|
|
+ return Ok(None);
|
|
|
|
|
+ };
|
|
|
|
|
+ parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_permission_mode_label(
|
|
|
|
|
+ mode: &str,
|
|
|
|
|
+ context: &str,
|
|
|
|
|
+) -> Result<ResolvedPermissionMode, ConfigError> {
|
|
|
|
|
+ match mode {
|
|
|
|
|
+ "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
|
|
|
|
|
+ "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
|
|
|
|
|
+ "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
|
|
|
|
|
+ other => Err(ConfigError::Parse(format!(
|
|
|
|
|
+ "{context}: unsupported permission mode {other}"
|
|
|
|
|
+ ))),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn parse_optional_oauth_config(
|
|
fn parse_optional_oauth_config(
|
|
|
root: &JsonValue,
|
|
root: &JsonValue,
|
|
|
context: &str,
|
|
context: &str,
|
|
@@ -594,7 +684,8 @@ fn deep_merge_objects(
|
|
|
#[cfg(test)]
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
mod tests {
|
|
|
use super::{
|
|
use super::{
|
|
|
- ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
|
|
|
|
|
|
+ ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
|
|
|
|
|
+ CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
|
|
};
|
|
};
|
|
|
use crate::json::JsonValue;
|
|
use crate::json::JsonValue;
|
|
|
use std::fs;
|
|
use std::fs;
|
|
@@ -635,14 +726,24 @@ mod tests {
|
|
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
+ fs::write(
|
|
|
|
|
+ home.parent().expect("home parent").join(".claude.json"),
|
|
|
|
|
+ r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
|
|
|
|
|
+ )
|
|
|
|
|
+ .expect("write user compat config");
|
|
|
fs::write(
|
|
fs::write(
|
|
|
home.join("settings.json"),
|
|
home.join("settings.json"),
|
|
|
- r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#,
|
|
|
|
|
|
|
+ r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
|
|
|
)
|
|
)
|
|
|
.expect("write user settings");
|
|
.expect("write user settings");
|
|
|
|
|
+ fs::write(
|
|
|
|
|
+ cwd.join(".claude.json"),
|
|
|
|
|
+ r#"{"model":"project-compat","env":{"B":"2"}}"#,
|
|
|
|
|
+ )
|
|
|
|
|
+ .expect("write project compat config");
|
|
|
fs::write(
|
|
fs::write(
|
|
|
cwd.join(".claude").join("settings.json"),
|
|
cwd.join(".claude").join("settings.json"),
|
|
|
- r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#,
|
|
|
|
|
|
|
+ r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
|
|
|
)
|
|
)
|
|
|
.expect("write project settings");
|
|
.expect("write project settings");
|
|
|
fs::write(
|
|
fs::write(
|
|
@@ -656,25 +757,37 @@ mod tests {
|
|
|
.expect("config should load");
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
|
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
|
|
- assert_eq!(loaded.loaded_entries().len(), 3);
|
|
|
|
|
|
|
+ assert_eq!(loaded.loaded_entries().len(), 5);
|
|
|
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
|
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
loaded.get("model"),
|
|
loaded.get("model"),
|
|
|
Some(&JsonValue::String("opus".to_string()))
|
|
Some(&JsonValue::String("opus".to_string()))
|
|
|
);
|
|
);
|
|
|
|
|
+ assert_eq!(loaded.model(), Some("opus"));
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ loaded.permission_mode(),
|
|
|
|
|
+ Some(ResolvedPermissionMode::WorkspaceWrite)
|
|
|
|
|
+ );
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
loaded
|
|
loaded
|
|
|
.get("env")
|
|
.get("env")
|
|
|
.and_then(JsonValue::as_object)
|
|
.and_then(JsonValue::as_object)
|
|
|
.expect("env object")
|
|
.expect("env object")
|
|
|
.len(),
|
|
.len(),
|
|
|
- 2
|
|
|
|
|
|
|
+ 4
|
|
|
);
|
|
);
|
|
|
assert!(loaded
|
|
assert!(loaded
|
|
|
.get("hooks")
|
|
.get("hooks")
|
|
|
.and_then(JsonValue::as_object)
|
|
.and_then(JsonValue::as_object)
|
|
|
.expect("hooks object")
|
|
.expect("hooks object")
|
|
|
.contains_key("PreToolUse"));
|
|
.contains_key("PreToolUse"));
|
|
|
|
|
+ assert!(loaded
|
|
|
|
|
+ .get("hooks")
|
|
|
|
|
+ .and_then(JsonValue::as_object)
|
|
|
|
|
+ .expect("hooks object")
|
|
|
|
|
+ .contains_key("PostToolUse"));
|
|
|
|
|
+ assert!(loaded.mcp().get("home").is_some());
|
|
|
|
|
+ assert!(loaded.mcp().get("project").is_some());
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
}
|
|
}
|