Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 1 addition & 85 deletions ROADMAP.md

Large diffs are not rendered by default.

11 changes: 0 additions & 11 deletions progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -365,14 +365,3 @@ US-021 COMPLETED (Request body size pre-flight check - from dogfood findings)
- Tests: 5 new tests for size estimation and limit checking

PROJECT STATUS: COMPLETE (21/21 stories)

Iteration 2026-04-29 - ROADMAP #96 COMPLETED
------------------------------------------------
- Pulled origin/main: already up to date.
- Selected ROADMAP #96 as a small repo-local Immediate Backlog item: the `claw --help` Resume-safe command summary leaked slash-command stubs despite the main Interactive command listing filtering them.
- Files: rust/crates/rusty-claude-cli/src/main.rs, ROADMAP.md, progress.txt.
- Changed help rendering to filter `resume_supported_slash_commands()` through `STUB_COMMANDS` before building the Resume-safe one-liner.
- Added `stub_commands_absent_from_resume_safe_help` regression coverage so future stub additions cannot leak into the Resume-safe summary.
- Targeted verification: `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture` passed; `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` passed.
- Format/check verification: `cargo fmt --all --check`, `git diff --check`, and `cargo check -p rusty-claude-cli` passed.
- Broader clippy note: `cargo clippy -p rusty-claude-cli --all-targets -- -D warnings` is blocked by pre-existing `clippy::unnecessary_wraps` failures in `rust/crates/commands/src/lib.rs` (`render_mcp_report_for`, `render_mcp_report_json_for`), outside this diff.
71 changes: 71 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 Expand Up @@ -1388,6 +1445,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("<!") {
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: payload.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
serde_json::from_str::<ChatCompletionChunk>(&payload)
.map(Some)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
Expand Down
1 change: 1 addition & 0 deletions rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,7 @@ 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 },
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
Loading