mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
feat: config discovery and CLAUDE.md loading (cherry-picked from rcc/runtime)
This commit is contained in:
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user