feat: config discovery and CLAUDE.md loading (cherry-picked from rcc/runtime)

This commit is contained in:
Yeachan-Heo
2026-04-01 00:40:34 +00:00
parent 863958b94c
commit d6341d54c1
5 changed files with 200 additions and 28 deletions

View File

@@ -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( oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
&JsonValue::Object(merged.clone()), model: parse_optional_model(&merged_value),
"merged settings.oauth", 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) let parsed = match JsonValue::parse(&contents) {
.map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?; Ok(parsed) => parsed,
let object = parsed.as_object().ok_or_else(|| { Err(error) if is_legacy_config => return Ok(None),
ConfigError::Parse(format!( 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");
} }

View File

@@ -408,8 +408,7 @@ mod tests {
.sum::<i32>(); .sum::<i32>();
Ok(total.to_string()) Ok(total.to_string())
}); });
let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) let permission_policy = PermissionPolicy::new(PermissionMode::Prompt);
.with_tool_requirement("add", PermissionMode::DangerFullAccess);
let system_prompt = SystemPromptBuilder::new() let system_prompt = SystemPromptBuilder::new()
.with_project_context(ProjectContext { .with_project_context(ProjectContext {
cwd: PathBuf::from("/tmp/project"), cwd: PathBuf::from("/tmp/project"),
@@ -488,8 +487,7 @@ mod tests {
Session::new(), Session::new(),
SingleCallApiClient, SingleCallApiClient,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::WorkspaceWrite) PermissionPolicy::new(PermissionMode::Prompt),
.with_tool_requirement("blocked", PermissionMode::DangerFullAccess),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -538,7 +536,7 @@ mod tests {
session, session,
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::ReadOnly), PermissionPolicy::new(PermissionMode::Allow),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -565,7 +563,7 @@ mod tests {
Session::new(), Session::new(),
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::ReadOnly), PermissionPolicy::new(PermissionMode::Allow),
vec!["system".to_string()], vec!["system".to_string()],
); );
runtime.run_turn("a", None).expect("turn a"); runtime.run_turn("a", None).expect("turn a");

View File

@@ -25,7 +25,8 @@ pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
}; };
pub use conversation::{ pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
@@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi
pub use usage::{ pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
}; };
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

View File

@@ -448,7 +448,6 @@ fn decode_hex(byte: u8) -> Result<u8, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use super::{ use super::{
@@ -470,10 +469,7 @@ mod tests {
} }
fn env_lock() -> std::sync::MutexGuard<'static, ()> { fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); crate::test_env_lock()
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock")
} }
fn temp_config_home() -> std::path::PathBuf { fn temp_config_home() -> std::path::PathBuf {

View File

@@ -201,6 +201,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
dir.join("CLAUDE.md"), dir.join("CLAUDE.md"),
dir.join("CLAUDE.local.md"), dir.join("CLAUDE.local.md"),
dir.join(".claude").join("CLAUDE.md"), dir.join(".claude").join("CLAUDE.md"),
dir.join(".claude").join("instructions.md"),
] { ] {
push_context_file(&mut files, candidate)?; push_context_file(&mut files, candidate)?;
} }
@@ -468,6 +469,10 @@ mod tests {
std::env::temp_dir().join(format!("runtime-prompt-{nanos}")) std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
} }
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_env_lock()
}
#[test] #[test]
fn discovers_instruction_files_from_ancestor_chain() { fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir(); let root = temp_dir();
@@ -477,10 +482,21 @@ mod tests {
fs::write(root.join("CLAUDE.local.md"), "local instructions") fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions"); .expect("write local instructions");
fs::create_dir_all(root.join("apps")).expect("apps dir"); fs::create_dir_all(root.join("apps")).expect("apps dir");
fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir");
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions") fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
.expect("write apps instructions"); .expect("write apps instructions");
fs::write(
root.join("apps").join(".claude").join("instructions.md"),
"apps dot claude instructions",
)
.expect("write apps dot claude instructions");
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules") fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
.expect("write nested rules"); .expect("write nested rules");
fs::write(
nested.join(".claude").join("instructions.md"),
"nested instructions",
)
.expect("write nested instructions");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load"); let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let contents = context let contents = context
@@ -495,7 +511,9 @@ mod tests {
"root instructions", "root instructions",
"local instructions", "local instructions",
"apps instructions", "apps instructions",
"nested rules" "apps dot claude instructions",
"nested rules",
"nested instructions"
] ]
); );
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
@@ -574,7 +592,12 @@ mod tests {
) )
.expect("write settings"); .expect("write settings");
let _guard = env_lock();
let previous = std::env::current_dir().expect("cwd"); let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
std::env::set_var("HOME", &root);
std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home"));
std::env::set_current_dir(&root).expect("change cwd"); std::env::set_current_dir(&root).expect("change cwd");
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8") let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
.expect("system prompt should load") .expect("system prompt should load")
@@ -584,6 +607,16 @@ mod tests {
", ",
); );
std::env::set_current_dir(previous).expect("restore cwd"); std::env::set_current_dir(previous).expect("restore cwd");
if let Some(value) = original_home {
std::env::set_var("HOME", value);
} else {
std::env::remove_var("HOME");
}
if let Some(value) = original_claude_home {
std::env::set_var("CLAUDE_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
assert!(prompt.contains("Project rules")); assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode")); assert!(prompt.contains("permissionMode"));
@@ -631,6 +664,29 @@ mod tests {
assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count()); assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count());
} }
#[test]
fn discovers_dot_claude_instructions_markdown() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
fs::write(
nested.join(".claude").join("instructions.md"),
"instruction markdown",
)
.expect("write instructions.md");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
assert!(context
.instruction_files
.iter()
.any(|file| file.path.ends_with(".claude/instructions.md")));
assert!(
render_instruction_files(&context.instruction_files).contains("instruction markdown")
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test] #[test]
fn renders_instruction_file_metadata() { fn renders_instruction_file_metadata() {
let rendered = render_instruction_files(&[ContextFile { let rendered = render_instruction_files(&[ContextFile {