Răsfoiți Sursa

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

Yeachan-Heo 2 luni în urmă
părinte
comite
b4e4070216

+ 128 - 15
rust/crates/runtime/src/config.rs

@@ -14,6 +14,13 @@ pub enum ConfigSource {
     Local,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ResolvedPermissionMode {
+    ReadOnly,
+    WorkspaceWrite,
+    DangerFullAccess,
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ConfigEntry {
     pub source: ConfigSource,
@@ -31,6 +38,8 @@ pub struct RuntimeConfig {
 pub struct RuntimeFeatureConfig {
     mcp: McpConfigCollection,
     oauth: Option<OAuthConfig>,
+    model: Option<String>,
+    permission_mode: Option<ResolvedPermissionMode>,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -165,11 +174,23 @@ impl ConfigLoader {
 
     #[must_use]
     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![
+            ConfigEntry {
+                source: ConfigSource::User,
+                path: user_legacy_path,
+            },
             ConfigEntry {
                 source: ConfigSource::User,
                 path: self.config_home.join("settings.json"),
             },
+            ConfigEntry {
+                source: ConfigSource::Project,
+                path: self.cwd.join(".claude.json"),
+            },
             ConfigEntry {
                 source: ConfigSource::Project,
                 path: self.cwd.join(".claude").join("settings.json"),
@@ -195,14 +216,15 @@ impl ConfigLoader {
             loaded_entries.push(entry);
         }
 
+        let merged_value = JsonValue::Object(merged.clone());
+
         let feature_config = RuntimeFeatureConfig {
             mcp: McpConfigCollection {
                 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 {
@@ -257,6 +279,16 @@ impl RuntimeConfig {
     pub fn oauth(&self) -> Option<&OAuthConfig> {
         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 {
@@ -269,6 +301,16 @@ impl RuntimeFeatureConfig {
     pub fn oauth(&self) -> Option<&OAuthConfig> {
         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 {
@@ -307,6 +349,7 @@ impl McpServerConfig {
 fn read_optional_json_object(
     path: &Path,
 ) -> 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) {
         Ok(contents) => contents,
         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()));
     }
 
-    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",
             path.display()
-        ))
-    })?;
+        )));
+    };
     Ok(Some(object.clone()))
 }
 
@@ -355,6 +404,47 @@ fn merge_mcp_servers(
     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(
     root: &JsonValue,
     context: &str,
@@ -594,7 +684,8 @@ fn deep_merge_objects(
 #[cfg(test)]
 mod tests {
     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 std::fs;
@@ -635,14 +726,24 @@ mod tests {
         fs::create_dir_all(cwd.join(".claude")).expect("project 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(
             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");
+        fs::write(
+            cwd.join(".claude.json"),
+            r#"{"model":"project-compat","env":{"B":"2"}}"#,
+        )
+        .expect("write project compat config");
         fs::write(
             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");
         fs::write(
@@ -656,25 +757,37 @@ mod tests {
             .expect("config should load");
 
         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.get("model"),
             Some(&JsonValue::String("opus".to_string()))
         );
+        assert_eq!(loaded.model(), Some("opus"));
+        assert_eq!(
+            loaded.permission_mode(),
+            Some(ResolvedPermissionMode::WorkspaceWrite)
+        );
         assert_eq!(
             loaded
                 .get("env")
                 .and_then(JsonValue::as_object)
                 .expect("env object")
                 .len(),
-            2
+            4
         );
         assert!(loaded
             .get("hooks")
             .and_then(JsonValue::as_object)
             .expect("hooks object")
             .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");
     }

+ 4 - 6
rust/crates/runtime/src/conversation.rs

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

+ 10 - 1
rust/crates/runtime/src/lib.rs

@@ -25,7 +25,8 @@ pub use config::{
     ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
     McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
     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::{
     ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
@@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi
 pub use usage::{
     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)
+}

+ 1 - 5
rust/crates/runtime/src/oauth.rs

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

+ 57 - 1
rust/crates/runtime/src/prompt.rs

@@ -201,6 +201,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
             dir.join("CLAUDE.md"),
             dir.join("CLAUDE.local.md"),
             dir.join(".claude").join("CLAUDE.md"),
+            dir.join(".claude").join("instructions.md"),
         ] {
             push_context_file(&mut files, candidate)?;
         }
@@ -468,6 +469,10 @@ mod tests {
         std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
     }
 
+    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+        crate::test_env_lock()
+    }
+
     #[test]
     fn discovers_instruction_files_from_ancestor_chain() {
         let root = temp_dir();
@@ -477,10 +482,21 @@ mod tests {
         fs::write(root.join("CLAUDE.local.md"), "local instructions")
             .expect("write local instructions");
         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")
             .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")
             .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 contents = context
@@ -495,7 +511,9 @@ mod tests {
                 "root instructions",
                 "local instructions",
                 "apps instructions",
-                "nested rules"
+                "apps dot claude instructions",
+                "nested rules",
+                "nested instructions"
             ]
         );
         fs::remove_dir_all(root).expect("cleanup temp dir");
@@ -574,7 +592,12 @@ mod tests {
         )
         .expect("write settings");
 
+        let _guard = env_lock();
         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");
         let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
             .expect("system prompt should load")
@@ -584,6 +607,16 @@ mod tests {
 ",
             );
         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("permissionMode"));
@@ -631,6 +664,29 @@ mod tests {
         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]
     fn renders_instruction_file_metadata() {
         let rendered = render_instruction_files(&[ContextFile {