From 9c8375da9917d65f497a5afc0087262750d43cf1 Mon Sep 17 00:00:00 2001 From: bellman Date: Wed, 3 Jun 2026 21:01:48 +0900 Subject: [PATCH] feat: import project instruction rules --- .gitignore | 1 + USAGE.md | 17 ++ rust/crates/runtime/src/config.rs | 135 +++++++++++++ rust/crates/runtime/src/config_validate.rs | 40 ++++ rust/crates/runtime/src/lib.rs | 5 +- rust/crates/runtime/src/prompt.rs | 222 ++++++++++++++++++++- 6 files changed, 414 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 6259e5b7..fcf4f9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ archive/ # Claw Code local artifacts .claw/settings.local.json .claw/sessions/ +.claw/rules.local/ .clawhip/ status-help.txt # Legacy Python port session scratch artifacts diff --git a/USAGE.md b/USAGE.md index 7785403d..190dc2c8 100644 --- a/USAGE.md +++ b/USAGE.md @@ -519,6 +519,23 @@ Runtime config is loaded in this order, with later entries overriding earlier on 4. `/.claw/settings.json` 5. `/.claw/settings.local.json` +## Project instruction rules + +In addition to root instruction files such as `CLAUDE.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from: + +- `/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules. +- `/.claw/rules.local/` for personal local rules; this path is gitignored. + +By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file: + +```json +{ + "rulesImport": "none" +} +``` + +Use `"auto"` (the default) to import every supported framework, `"none"` to load only Claw instruction/rules files, or an array such as `["cursor", "copilot"]` to import selected frameworks. + ## Mock parity harness The workspace includes a deterministic Anthropic-compatible mock service and parity harness. diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 0379e31b..527cddac 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -95,6 +95,32 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + rules_import: RulesImportConfig, +} + +/// Controls which external AI coding framework rules are imported into the system prompt. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum RulesImportConfig { + /// Import from all supported frameworks when files are detected. + #[default] + Auto, + /// Do not import external framework rules; keep Claw instruction files only. + None, + /// Import only the named frameworks. + List(Vec), +} + +impl RulesImportConfig { + #[must_use] + pub fn should_import(&self, framework: &str) -> bool { + match self { + Self::Auto => true, + Self::None => false, + Self::List(frameworks) => frameworks + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(framework)), + } + } } /// Ordered chain of fallback model identifiers used when the primary @@ -353,6 +379,7 @@ impl ConfigLoader { sandbox: parse_optional_sandbox_config(&merged_value)?, provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, trusted_roots: parse_optional_trusted_roots(&merged_value)?, + rules_import: parse_optional_rules_import(&merged_value)?, }; Ok(RuntimeConfig { @@ -410,6 +437,7 @@ impl ConfigLoader { sandbox: parse_optional_sandbox_config(&merged_value)?, provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, trusted_roots: parse_optional_trusted_roots(&merged_value)?, + rules_import: parse_optional_rules_import(&merged_value)?, }; let config = RuntimeConfig { @@ -511,6 +539,11 @@ impl RuntimeConfig { &self.feature_config.trusted_roots } + #[must_use] + pub fn rules_import(&self) -> &RulesImportConfig { + &self.feature_config.rules_import + } + /// Merge config-level default trusted roots with per-call roots. /// /// Config roots are defaults and are kept first; per-call roots extend the @@ -591,6 +624,11 @@ impl RuntimeFeatureConfig { &self.trusted_roots } + #[must_use] + pub fn rules_import(&self) -> &RulesImportConfig { + &self.rules_import + } + /// Merge this config's default trusted roots with per-call roots. #[must_use] pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec { @@ -1162,6 +1200,37 @@ fn parse_optional_trusted_roots(root: &JsonValue) -> Result, ConfigE ) } +fn parse_optional_rules_import(root: &JsonValue) -> Result { + let Some(object) = root.as_object() else { + return Ok(RulesImportConfig::default()); + }; + let Some(value) = object.get("rulesImport") else { + return Ok(RulesImportConfig::default()); + }; + + match value { + JsonValue::String(value) if value.eq_ignore_ascii_case("auto") => Ok(RulesImportConfig::Auto), + JsonValue::String(value) if value.eq_ignore_ascii_case("none") => Ok(RulesImportConfig::None), + JsonValue::String(value) => Err(ConfigError::Parse(format!( + "merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names, got \"{value}\"" + ))), + JsonValue::Array(values) => values + .iter() + .map(|item| { + item.as_str().map(str::to_string).ok_or_else(|| { + ConfigError::Parse( + "merged settings.rulesImport: array entries must be strings".to_string(), + ) + }) + }) + .collect::, _>>() + .map(RulesImportConfig::List), + _ => Err(ConfigError::Parse( + "merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names".to_string(), + )), + } +} + fn parse_filesystem_mode_label(value: &str) -> Result { match value { "off" => Ok(FilesystemIsolationMode::Off), @@ -1724,6 +1793,72 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn parses_rules_import_config() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"rulesImport": ["cursor", "copilot"]}"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert!(loaded.rules_import().should_import("cursor")); + assert!(loaded.rules_import().should_import("copilot")); + assert!(!loaded.rules_import().should_import("windsurf")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn rules_import_none_disables_external_frameworks() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write(home.join("settings.json"), r#"{"rulesImport": "none"}"#) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert!(!loaded.rules_import().should_import("cursor")); + assert!(!loaded.rules_import().should_import("copilot")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn rejects_rules_import_array_with_non_string_entries() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"rulesImport": ["cursor", 42]}"#, + ) + .expect("write settings"); + + let error = ConfigLoader::new(&cwd, &home) + .load() + .expect_err("config should fail"); + + assert!(error.to_string().contains("rulesImport")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn parses_trusted_roots_from_settings() { // given diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 3ea064eb..4b33e323 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -92,6 +92,7 @@ enum FieldType { Bool, Object, StringArray, + RulesImport, Number, } @@ -102,6 +103,7 @@ impl FieldType { Self::Bool => "a boolean", Self::Object => "an object", Self::StringArray => "an array of strings", + Self::RulesImport => "a string or an array of strings", Self::Number => "a number", } } @@ -114,6 +116,12 @@ impl FieldType { Self::StringArray => value .as_array() .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())), + Self::RulesImport => { + value.as_str().is_some() + || value + .as_array() + .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())) + } Self::Number => value.as_i64().is_some(), } } @@ -201,6 +209,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "provider", expected: FieldType::Object, }, + FieldSpec { + name: "rulesImport", + expected: FieldType::RulesImport, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ @@ -705,6 +717,34 @@ mod tests { assert_eq!(result.errors[0].field, "hooks.BadHook"); } + #[test] + fn validates_rules_import_string_and_array_forms() { + for source in [ + r#"{"rulesImport":"auto"}"#, + r#"{"rulesImport":"none"}"#, + r#"{"rulesImport":["cursor","copilot"]}"#, + ] { + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + + assert!(result.errors.is_empty(), "{source}: {:?}", result.errors); + } + } + + #[test] + fn rejects_rules_import_wrong_type() { + let source = r#"{"rulesImport":42}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "rulesImport"); + } + #[test] fn validates_nested_permissions_keys() { // given diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index f0ab67c3..48b16d07 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -69,8 +69,9 @@ pub use config::{ McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, - RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig, - RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, + RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, + RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig, + CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 44a3669d..1e5f2b1e 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::config::{ConfigError, ConfigLoader, RuntimeConfig}; +use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig}; use crate::git_context::GitContext; /// Errors raised while assembling the final system prompt. @@ -86,7 +86,24 @@ impl ProjectContext { current_date: impl Into, ) -> std::io::Result { let cwd = cwd.into(); - let instruction_files = discover_instruction_files(&cwd)?; + let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?; + Ok(Self { + cwd, + current_date: current_date.into(), + git_status: None, + git_diff: None, + git_context: None, + instruction_files, + }) + } + + pub fn discover_with_rules_import( + cwd: impl Into, + current_date: impl Into, + rules_import: &RulesImportConfig, + ) -> std::io::Result { + let cwd = cwd.into(); + let instruction_files = discover_instruction_files(&cwd, rules_import)?; Ok(Self { cwd, current_date: current_date.into(), @@ -109,6 +126,18 @@ impl ProjectContext { } } +fn discover_with_git_and_rules_import( + cwd: impl Into, + current_date: impl Into, + rules_import: &RulesImportConfig, +) -> std::io::Result { + let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?; + context.git_status = read_git_status(&context.cwd); + context.git_diff = read_git_diff(&context.cwd); + context.git_context = GitContext::detect(&context.cwd); + Ok(context) +} + /// Builder for the runtime system prompt and dynamic environment sections. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SystemPromptBuilder { @@ -227,7 +256,10 @@ pub fn prepend_bullets(items: Vec) -> Vec { items.into_iter().map(|item| format!(" - {item}")).collect() } -fn discover_instruction_files(cwd: &Path) -> std::io::Result> { +fn discover_instruction_files( + cwd: &Path, + rules_import: &RulesImportConfig, +) -> std::io::Result> { let mut directories = Vec::new(); let mut cursor = Some(cwd); while let Some(dir) = cursor { @@ -248,11 +280,17 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { ] { push_context_file(&mut files, candidate)?; } + push_rules_dir(&mut files, dir.join(".claw").join("rules"))?; + push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?; + push_framework_imports(&mut files, &dir, rules_import)? } Ok(dedupe_instruction_files(files)) } fn push_context_file(files: &mut Vec, path: PathBuf) -> std::io::Result<()> { + if path.is_dir() { + return Ok(()); + } match fs::read_to_string(&path) { Ok(content) if !content.trim().is_empty() => { files.push(ContextFile { path, content }); @@ -264,6 +302,64 @@ fn push_context_file(files: &mut Vec, path: PathBuf) -> std::io::Re } } +fn push_rules_dir(files: &mut Vec, dir: PathBuf) -> std::io::Result<()> { + if dir.is_file() { + return Ok(()); + } + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + let mut paths = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file() && is_supported_rule_file(path)) + .collect::>(); + paths.sort(); + for path in paths { + push_context_file(files, path)?; + } + Ok(()) +} + +fn is_supported_rule_file(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "md" | "txt" | "mdc" + ) + }) +} + +fn push_framework_imports( + files: &mut Vec, + dir: &Path, + rules_import: &RulesImportConfig, +) -> std::io::Result<()> { + if rules_import.should_import("cursor") { + push_context_file(files, dir.join(".cursorrules"))?; + push_rules_dir(files, dir.join(".cursor").join("rules"))?; + } + if rules_import.should_import("copilot") { + push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?; + } + if rules_import.should_import("windsurf") { + push_context_file(files, dir.join(".windsurfrules"))?; + push_rules_dir(files, dir.join(".windsurfrules"))?; + } + if rules_import.should_import("plandex") { + push_context_file(files, dir.join(".plandex").join("instructions.md"))?; + } + if rules_import.should_import("crush") { + push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?; + push_rules_dir(files, dir.join(".crush").join("rules"))?; + } + Ok(()) +} + fn read_git_status(cwd: &Path) -> Option { let output = Command::new("git") .args(["--no-optional-locks", "status", "--short", "--branch"]) @@ -478,8 +574,9 @@ pub fn load_system_prompt( model_family: ModelFamilyIdentity, ) -> Result, PromptBuildError> { let cwd = cwd.into(); - let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?; let config = ConfigLoader::default_for(&cwd).load()?; + let project_context = + discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?; Ok(SystemPromptBuilder::new() .with_os(os_name, os_version) .with_model_family(model_family) @@ -592,6 +689,78 @@ mod tests { } } + #[test] + fn discovers_claw_rules_files_in_sorted_order() { + let root = temp_dir(); + let rules = root.join(".claw").join("rules"); + let local_rules = root.join(".claw").join("rules.local"); + fs::create_dir_all(&rules).expect("rules dir"); + fs::create_dir_all(&local_rules).expect("local rules dir"); + fs::write(rules.join("b.txt"), "b rule").expect("write b rule"); + fs::write(rules.join("a.md"), "a rule").expect("write a rule"); + fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored"); + fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule"); + + let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load"); + let contents = context + .instruction_files + .iter() + .map(|file| file.content.as_str()) + .collect::>(); + + assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn rules_import_none_suppresses_external_framework_rules() { + let root = temp_dir(); + fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir"); + fs::write( + root.join(".claw").join("rules").join("project.md"), + "claw rule", + ) + .expect("write claw rule"); + fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule"); + + let context = ProjectContext::discover_with_rules_import( + &root, + "2026-03-31", + &crate::config::RulesImportConfig::None, + ) + .expect("context should load"); + let rendered = render_instruction_files(&context.instruction_files); + + assert!(rendered.contains("claw rule")); + assert!(!rendered.contains("cursor rule")); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn rules_import_list_loads_only_selected_framework_rules() { + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule"); + fs::create_dir_all(root.join(".github")).expect("github dir"); + fs::write( + root.join(".github").join("copilot-instructions.md"), + "copilot rule", + ) + .expect("write copilot rule"); + + let context = ProjectContext::discover_with_rules_import( + &root, + "2026-03-31", + &crate::config::RulesImportConfig::List(vec!["copilot".to_string()]), + ) + .expect("context should load"); + let rendered = render_instruction_files(&context.instruction_files); + + assert!(rendered.contains("copilot rule")); + assert!(!rendered.contains("cursor rule")); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn discovers_instruction_files_from_ancestor_chain() { let root = temp_dir(); @@ -935,6 +1104,51 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn load_system_prompt_respects_rules_import_config() { + let root = temp_dir(); + fs::create_dir_all(root.join(".claw")).expect("claw dir"); + fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule"); + fs::write( + root.join(".claw").join("settings.json"), + r#"{"rulesImport":"none"}"#, + ) + .expect("write settings"); + + let _guard = env_lock(); + ensure_valid_cwd(); + let previous = std::env::current_dir().expect("cwd"); + let original_home = std::env::var("HOME").ok(); + let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("HOME", &root); + std::env::set_var("CLAW_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", + ModelFamilyIdentity::Claude, + ) + .expect("system prompt should load") + .join("\n\n"); + 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_claw_home { + std::env::set_var("CLAW_CONFIG_HOME", value); + } else { + std::env::remove_var("CLAW_CONFIG_HOME"); + } + + assert!(!prompt.contains("cursor rule")); + assert!(prompt.contains("rulesImport")); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn renders_default_claude_model_family_identity() { // given: a prompt builder without an explicit model family override