From c540e6f9ac4640998c9f30da347ab7db92629342 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 28 Apr 2026 11:03:45 -0500 Subject: [PATCH 1/8] feat: project rules with .claw/rules/ and multi-framework auto-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a project rules system for loading instruction files into the system prompt, plus auto-import from other AI coding frameworks. ## New rule locations (auto-loaded, sorted alphabetically) - `.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` ## Auto-import from other frameworks 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/` ## Config option `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 --- rust/crates/runtime/src/config.rs | 59 +++++++++++++ rust/crates/runtime/src/config_validate.rs | 4 + rust/crates/runtime/src/lib.rs | 6 +- rust/crates/runtime/src/prompt.rs | 97 ++++++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..dbcdf80204 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -65,6 +65,32 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + 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), + #[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)), + } + } } /// Ordered chain of fallback model identifiers used when the primary @@ -315,6 +341,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 { @@ -414,6 +441,10 @@ impl RuntimeConfig { pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots } + + pub fn rules_import(&self) -> &RulesImportConfig { + &self.feature_config.rules_import + } } impl RuntimeFeatureConfig { @@ -914,6 +945,34 @@ 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(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 = 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"# + ))), + } +} fn parse_filesystem_mode_label(value: &str) -> Result { match value { "off" => Ok(FilesystemIsolationMode::Off), diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 7a9c1c4adc..e8767b2aa7 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -197,6 +197,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "trustedRoots", expected: FieldType::StringArray, }, + FieldSpec { + name: "rulesImport", + expected: FieldType::String, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index c1108d3dc7..f32497e870 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -60,9 +60,9 @@ pub use config::{ ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, - ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, - RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig, - CLAW_SETTINGS_SCHEMA_NAME, + ProviderFallbackConfig, ResolvedPermissionMode, 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 1e6c4eda85..b875b77380 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -237,6 +237,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { let mut files = Vec::new(); for dir in directories { + // Single-file instruction files (existing) for candidate in [ dir.join("CLAUDE.md"), dir.join("CLAUDE.local.md"), @@ -245,10 +246,106 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { ] { push_context_file(&mut files, candidate)?; } + // .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)?; } Ok(dedupe_instruction_files(files)) } +/// Load all .md files from a rules directory, sorted alphabetically. +fn push_rules_dir(files: &mut Vec, 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 = 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, 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 { + 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 { + 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()); + } + } + } + None +} + fn push_context_file(files: &mut Vec, path: PathBuf) -> std::io::Result<()> { match fs::read_to_string(&path) { Ok(content) if !content.trim().is_empty() => { From 841258ef7a90134d153abe51eea4bb8223331ec7 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:32:40 -0500 Subject: [PATCH 2/8] fix: make id field optional in OpenAI response parsing Some OpenAI-compatible providers (e.g., GLM-5) omit the `id` field in streaming and non-streaming responses. Adding #[serde(default)] allows the parser to accept these responses instead of failing with "missing field `id`". Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 2 ++ rust/crates/commands/src/lib.rs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..f0b340d7f9 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -728,6 +728,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -775,6 +776,7 @@ struct OpenAiUsage { #[derive(Debug, Deserialize)] struct ChatCompletionChunk { + #[serde(default)] id: String, #[serde(default)] model: Option, diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5e8f5eba8b..5570b2a615 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1472,10 +1472,15 @@ pub fn validate_slash_command_input( } "plan" => SlashCommand::Plan { mode: remainder }, "review" => SlashCommand::Review { scope: remainder }, + "team" => SlashCommand::Team { action: remainder }, "tasks" => SlashCommand::Tasks { args: remainder }, "theme" => SlashCommand::Theme { name: remainder }, "voice" => SlashCommand::Voice { mode: remainder }, "usage" => SlashCommand::Usage { scope: remainder }, +<<<<<<< HEAD +======= + "setup" => SlashCommand::Setup, +>>>>>>> 2f6a225 (fix: make id field optional in OpenAI response parsing) "rename" => SlashCommand::Rename { name: remainder }, "copy" => SlashCommand::Copy { target: remainder }, "hooks" => SlashCommand::Hooks { args: remainder }, From 4824a2e640a616bc9951e2af91e3bbed6a147031 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:40:25 -0500 Subject: [PATCH 3/8] chore: add install script for rebuild and link Adds scripts/install.sh that builds the release binary and links it to ~/.local/bin/claw. Run after code changes to update the CLI. Co-Authored-By: Claude Opus 4.7 --- rust/scripts/install.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100755 rust/scripts/install.sh diff --git a/rust/scripts/install.sh b/rust/scripts/install.sh new file mode 100755 index 0000000000..344a7b5c62 --- /dev/null +++ b/rust/scripts/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Build the release binary +cargo build --release + +# Link to ~/.local/bin +mkdir -p "$HOME/.local/bin" +ln -sf "$(pwd)/target/release/claw" "$HOME/.local/bin/claw" + +echo "✓ Claw installed to ~/.local/bin/claw" From e3d8cc2f31a4096c0df6d652a181e8934704da80 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:42:46 -0500 Subject: [PATCH 4/8] fix: detect HTML responses in streaming path When a provider returns HTML (e.g., error page, wrong endpoint) instead of JSON in an SSE stream, provide a clear error message instead of hanging or failing with a cryptic parse error. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index f0b340d7f9..4458c9e65d 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1390,6 +1390,20 @@ fn parse_sse_frame( }); } } + // Detect HTML or other non-JSON responses early for better error messages + let trimmed_payload = payload.trim(); + if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("(&payload) .map(Some) .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) From 879e756829ddaa44c9259a70677bbd93782ce2e7 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:46:32 -0500 Subject: [PATCH 5/8] fix: detect raw JSON errors in streaming path When a provider returns a JSON error (e.g., {"error":{"message":"..."}}) without SSE framing (no "data:" prefix), the SSE parser was silently ignoring it and hanging. Now detects and surfaces these errors. Also handles HTML responses that lack SSE framing. Co-Authored-By: Claude Opus 4.7 --- .../crates/api/src/providers/openai_compat.rs | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 4458c9e65d..2d0247320c 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1353,7 +1353,50 @@ fn parse_sse_frame( data_lines.push(data.trim_start()); } } + // If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise) if data_lines.is_empty() { + // Detect raw JSON error response (not SSE-framed) + if let Ok(raw) = serde_json::from_str::(trimmed) { + if let Some(err_obj) = raw.get("error") { + let msg = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("provider returned an error") + .to_string(); + let code = err_obj + .get("code") + .and_then(serde_json::Value::as_u64) + .map(|c| c as u16); + let status = reqwest::StatusCode::from_u16(code.unwrap_or(500)) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + return Err(ApiError::Api { + status, + error_type: err_obj + .get("type") + .and_then(|t| t.as_str()) + .map(str::to_owned), + message: Some(msg), + request_id: None, + body: trimmed.chars().take(500).collect(), + retryable: false, + suggested_action: suggested_action_for_status(status), + retry_after: None, + }); + } + } + // Detect HTML responses + if trimmed.starts_with('<') || trimmed.starts_with("(&payload) .map(Some) .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) From a0f683d1f9d218cb49851b5271f40d8b6a3968b0 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:04:43 -0500 Subject: [PATCH 6/8] fix: support reasoning_content and thinking fields in streaming Some providers (GLM, DeepSeek) emit reasoning tokens in `reasoning_content` or nested `thinking.content` fields instead of `content`. Added support for these fields so reasoning models work correctly. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 2d0247320c..a4d8c7169e 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -497,10 +497,12 @@ impl StreamState { } for choice in chunk.choices { + // Handle reasoning/thinking from various provider fields if let Some(reasoning) = choice .delta .reasoning_content .filter(|value| !value.is_empty()) + .or(choice.delta.thinking.and_then(|t| t.content).filter(|value| !value.is_empty())) { if !self.thinking_started { self.thinking_started = true; @@ -797,12 +799,21 @@ struct ChunkChoice { struct ChunkDelta { #[serde(default)] content: Option, + /// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content` #[serde(default)] reasoning_content: Option, + #[serde(default)] + thinking: Option, #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, } +#[derive(Debug, Default, Deserialize)] +struct ThinkingDelta { + #[serde(default)] + content: Option, +} + #[derive(Debug, Deserialize)] struct DeltaToolCall { #[serde(default)] From 719a46d2b5aa693c336da05b034224f5d04e8000 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:07:47 -0500 Subject: [PATCH 7/8] fix: make delta field optional in ChunkChoice The final streaming chunk from some providers contains only finish_reason and usage, with no delta field. Made it optional to prevent parse errors. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index a4d8c7169e..9da1e22d1a 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -790,6 +790,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, From 0908568c2f037b196a12484a6559b3038289cbf8 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 22:36:37 -0500 Subject: [PATCH 8/8] fix: bounds check in compact boundary loop When preserve_recent_messages == 0, raw_keep_from equals messages.len(), causing index out of bounds when accessing session.messages[k]. Added k >= session.messages.len() check to prevent panic. Reason: Compaction with preserve_recent_messages=0 triggered OOB access when checking for tool-use/tool-result pair preservation at boundary. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/compact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e4fd3db0d3..03f04053cb 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -128,7 +128,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio // is NOT an assistant message that contains a ToolUse block (i.e. the // pair is actually broken at the boundary). loop { - if k == 0 || k <= compacted_prefix_len { + if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() { break; } let first_preserved = &session.messages[k];