diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..9da1e22d1a 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; @@ -728,6 +730,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -775,6 +778,7 @@ struct OpenAiUsage { #[derive(Debug, Deserialize)] struct ChatCompletionChunk { + #[serde(default)] id: String, #[serde(default)] model: Option, @@ -786,6 +790,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, @@ -795,12 +800,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)] @@ -1351,7 +1365,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(" 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 }, 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]; 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() => { 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"