feat: project rules with .claw/rules/ and multi-framework auto-import

Adds a project rules system for loading instruction files into the
system prompt, plus auto-import from other AI coding frameworks.

- `.claw/rules/*.md` / `.claw/rules/*.txt` / `.claw/rules/*.mdc`
- `.claw/rules.local/*.md` — personal/local rules (gitignored)
- Existing: `CLAUDE.md`, `CLAUDE.local.md`, `.claw/CLAUDE.md`,
  `.claw/instructions.md`

When users switch to claw-code from another tool, their existing
rules are automatically detected and loaded:
- Cursor: `.cursorrules`, `.cursor/rules/`
- GitHub Copilot: `.github/copilot-instructions.md`
- Windsurf: `.windsurfrules`, `.windsurfrules/`
- Aider: `.aider.conf.yml` instructions block
- Pi (Plandex): `.plandex/instructions.md`, `.plandex/plan.md`
- OpenCode: `opencode.json` instructions field
- Crush: `.crush/CLAUDE.md`, `.crush/rules/`

`rulesImport` in settings.json controls framework auto-import:
- `"auto"` (default) — import from all detected frameworks
- `"none"` — only load .claw/rules/ and CLAUDE.md files
- `["cursor", "copilot"]` — import only from listed frameworks

```json
{
  "rulesImport": "auto"
}
```

💘 Generated with Crush

Assisted-by: GLM 5.1 FP8 via Crush <crush@charm.land>
This commit is contained in:
TheArchitectit 2026-04-28 11:03:45 -05:00
parent 72f50b840e
commit 5bb811aa5e
4 changed files with 180 additions and 0 deletions

View File

@ -209,6 +209,7 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig,
provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>,
<<<<<<< HEAD
<<<<<<< HEAD
api_timeout: ApiTimeoutConfig,
rules_import: RulesImportConfig,
@ -324,6 +325,34 @@ impl RuntimeProviderConfig {
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
=======
rules_import: RulesImportConfig,
}
/// Controls which external AI coding framework rules are auto-imported
/// into the system prompt.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RulesImportConfig {
/// Auto-import from all supported frameworks (Cursor, Copilot, Windsurf, Aider)
Auto,
/// No auto-import — only .claw/rules/ and CLAUDE.md files are loaded
None,
/// Import only from the listed frameworks
List(Vec<String>),
#[default]
/// Default: auto-import all detected frameworks
Default,
}
impl RulesImportConfig {
pub fn should_import(&self, framework: &str) -> bool {
match self {
Self::Auto | Self::Default => true,
Self::None => false,
Self::List(frameworks) => frameworks.iter().any(|f| f.eq_ignore_ascii_case(framework)),
}
}
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
}
/// Ordered chain of fallback model identifiers used when the primary
@ -769,6 +798,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)?,
<<<<<<< HEAD
provider: parse_optional_provider_config(&merged_value)?,
lsp: parse_optional_lsp_config(&merged_value)?,
<<<<<<< HEAD
@ -790,6 +820,9 @@ impl ConfigLoader {
>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration)
=======
>>>>>>> ab3550e5 (feat(lsp): add lspAutoStart config, remove unused LSP client/process/transport modules)
=======
rules_import: parse_optional_rules_import(&merged_value)?,
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
};
ConfigInspection {
@ -1048,6 +1081,7 @@ impl RuntimeConfig {
&self.feature_config.trusted_roots
}
<<<<<<< HEAD
#[must_use]
<<<<<<< HEAD
pub fn rules_import(&self) -> &RulesImportConfig {
@ -1088,6 +1122,11 @@ impl RuntimeConfig {
pub fn lsp_auto_start(&self) -> bool {
self.feature_config.lsp_auto_start
}
=======
pub fn rules_import(&self) -> &RulesImportConfig {
&self.feature_config.rules_import
}
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
}
impl RuntimeFeatureConfig {
@ -2312,6 +2351,7 @@ fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigE
)
}
<<<<<<< HEAD
fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RulesImportConfig::default());
@ -2362,6 +2402,36 @@ fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderCon
})
}
=======
fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, ConfigError> {
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(s) => match s.as_str() {
"auto" => Ok(RulesImportConfig::Auto),
"none" => Ok(RulesImportConfig::None),
other => Err(ConfigError::Parse(format!(
r#"merged settings.rulesImport: expected "auto", "none", or an array, got "{other}""#
))),
},
JsonValue::Array(arr) => {
let frameworks: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
Ok(RulesImportConfig::List(frameworks))
}
_ => Err(ConfigError::Parse(format!(
r#"merged settings.rulesImport: expected "auto", "none", or an array"#
))),
}
}
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
match value {
"off" => Ok(FilesystemIsolationMode::Off),

View File

@ -209,6 +209,7 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
expected: FieldType::StringArray,
},
FieldSpec {
<<<<<<< HEAD
name: "provider",
expected: FieldType::Object,
},
@ -237,6 +238,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "lspAutoStart",
expected: FieldType::Bool,
=======
name: "rulesImport",
expected: FieldType::String,
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
},
];

View File

@ -100,12 +100,18 @@ pub use config::{
>>>>>>> 1ff5617c (fix: sync all bug fixes to combined branch)
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
<<<<<<< HEAD
=======
>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration)
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
=======
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,

View File

@ -295,6 +295,7 @@ fn discover_instruction_files(
let mut files = Vec::new();
for dir in directories {
// Single-file instruction files (existing)
for candidate in [
dir.join("CLAUDE.md"),
dir.join("CLAW.md"),
@ -306,13 +307,23 @@ fn discover_instruction_files(
] {
push_context_file(&mut files, candidate)?;
}
<<<<<<< HEAD
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)?
=======
// .claw/rules/ directory: all .md files loaded in sorted order
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
// .claw/rules.local/ directory: personal/local rules (gitignored)
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
// Auto-import from other frameworks (Cursor, Copilot, Windsurf, Aider)
push_framework_imports(&mut files, &dir)?;
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
}
Ok(dedupe_instruction_files(files))
}
<<<<<<< HEAD
fn instruction_discovery_dirs(cwd: &Path) -> Vec<PathBuf> {
let boundary = nearest_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
let mut directories = Vec::new();
@ -335,6 +346,94 @@ fn nearest_git_root(cwd: &Path) -> Option<PathBuf> {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
=======
/// Load all .md files from a rules directory, sorted alphabetically.
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let mut paths: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|| p.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
|| p.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("mdc"))
})
.collect();
paths.sort();
for path in paths {
push_context_file(files, path)?;
}
Ok(())
}
/// Detect and import rules from other AI coding frameworks so that
/// users switching to claw-code don't have to duplicate their rules.
///
/// Supported frameworks:
/// - Cursor: .cursorrules, .cursor/rules/
/// - GitHub Copilot: .github/copilot-instructions.md
/// - Windsurf: .windsurfrules, .windsurfrules/
/// - Aider: .aider.conf.yml instructions block
/// - Pi (Plandex): .plandex/plan.md, .plandex/instructions.md
/// - OpenCode: opencode.json instructions field
/// - CrushCode / Crush: .crush/rules/, .crush/CLAUDE.md
fn push_framework_imports(files: &mut Vec<ContextFile>, dir: &Path) -> std::io::Result<()> {
// Cursor
push_context_file(files, dir.join(".cursorrules"))?;
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
// GitHub Copilot
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
// Windsurf
push_context_file(files, dir.join(".windsurfrules"))?;
push_rules_dir(files, dir.join(".windsurfrules"))?;
// Aider — reads the instruction lines from .aider.conf.yml
if let Some(aider_instructions) = read_aider_instructions(dir) {
files.push(ContextFile {
path: dir.join(".aider.conf.yml").join("instructions"),
content: aider_instructions,
});
}
// Pi (Plandex)
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
push_context_file(files, dir.join(".plandex").join("plan.md"))?;
// OpenCode — reads instructions from opencode.json config
if let Some(opencode_instructions) = read_opencode_instructions(dir) {
files.push(ContextFile {
path: dir.join("opencode.json").join("instructions"),
content: opencode_instructions,
});
}
// CrushCode / Crush
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
push_rules_dir(files, dir.join(".crush").join("rules"))?;
Ok(())
}
/// Extract instructions from an opencode.json config file.
/// OpenCode stores rules in a top-level "instructions" field.
fn read_opencode_instructions(dir: &Path) -> Option<String> {
let content = fs::read_to_string(dir.join("opencode.json")).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
parsed.get("instructions")?.as_str().map(str::to_owned)
}
/// Extract instruction lines from an .aider.conf.yml file.
/// Aider stores instructions like: `instructions: ...` or multiline block.
fn read_aider_instructions(dir: &Path) -> Option<String> {
let content = fs::read_to_string(dir.join(".aider.conf.yml")).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if let Some(val) = trimmed.strip_prefix("instructions:") {
let instruction = val.trim();
if !instruction.is_empty() {
return Some(instruction.to_owned());
}
}
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
}
None
}