Skip to content
57 changes: 57 additions & 0 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -728,6 +730,7 @@ impl ToolCallState {

#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
#[serde(default)]
id: String,
model: String,
choices: Vec<ChatChoice>,
Expand Down Expand Up @@ -775,6 +778,7 @@ struct OpenAiUsage {

#[derive(Debug, Deserialize)]
struct ChatCompletionChunk {
#[serde(default)]
id: String,
#[serde(default)]
model: Option<String>,
Expand All @@ -786,6 +790,7 @@ struct ChatCompletionChunk {

#[derive(Debug, Deserialize)]
struct ChunkChoice {
#[serde(default)]
delta: ChunkDelta,
#[serde(default)]
finish_reason: Option<String>,
Expand All @@ -795,12 +800,21 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option<String>,
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
}

#[derive(Debug, Default, Deserialize)]
struct ThinkingDelta {
#[serde(default)]
content: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DeltaToolCall {
#[serde(default)]
Expand Down Expand Up @@ -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::<serde_json::Value>(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("<!") {
return Err(ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_response".to_string()),
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
request_id: None,
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
return Ok(None);
}
let payload = data_lines.join("\n");
Expand Down
5 changes: 5 additions & 0 deletions rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/runtime/src/compact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
59 changes: 59 additions & 0 deletions rust/crates/runtime/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,32 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig,
provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>,
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)),
}
}
}

/// Ordered chain of fallback model identifiers used when the primary
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -914,6 +945,34 @@ fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigE
)
}


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"#
))),
}
}
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
match value {
"off" => Ok(FilesystemIsolationMode::Off),
Expand Down
4 changes: 4 additions & 0 deletions rust/crates/runtime/src/config_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "trustedRoots",
expected: FieldType::StringArray,
},
FieldSpec {
name: "rulesImport",
expected: FieldType::String,
},
];

const HOOKS_FIELDS: &[FieldSpec] = &[
Expand Down
6 changes: 3 additions & 3 deletions rust/crates/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions rust/crates/runtime/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {

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"),
Expand All @@ -245,10 +246,106 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
] {
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<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());
}
}
}
None
}

fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
match fs::read_to_string(&path) {
Ok(content) if !content.trim().is_empty() => {
Expand Down
11 changes: 11 additions & 0 deletions rust/scripts/install.sh
Original file line number Diff line number Diff line change
@@ -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"