diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..759a0b4b2c 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("(&payload) .map(Some) .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5e8f5eba8b..844ab5c0a2 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -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 }, 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..1484f8aa98 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -564,6 +564,92 @@ pub fn default_config_home() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".claw")) } +/// Save provider settings to the user-level `~/.claw/settings.json`. +/// Creates the file and directory if they don't exist. Sets file permissions +/// to `0o600` (owner read/write only) to protect stored API keys. +pub fn save_user_provider_settings( + kind: &str, + api_key: &str, + base_url: Option<&str>, + model: Option<&str>, +) -> Result<(), ConfigError> { + let config_home = default_config_home(); + fs::create_dir_all(&config_home).map_err(ConfigError::Io)?; + let settings_path = config_home.join("settings.json"); + + let mut root = read_settings_root(&settings_path); + + let mut provider = serde_json::Map::new(); + provider.insert("kind".to_string(), serde_json::Value::String(kind.to_string())); + provider.insert("apiKey".to_string(), serde_json::Value::String(api_key.to_string())); + if let Some(base_url) = base_url { + provider.insert("baseUrl".to_string(), serde_json::Value::String(base_url.to_string())); + } else { + provider.remove("baseUrl"); + } + root.insert("provider".to_string(), serde_json::Value::Object(provider)); + if let Some(model) = model { + root.insert("model".to_string(), serde_json::Value::String(model.to_string())); + } else { + root.remove("model"); + } + + write_settings_root(&settings_path, &root)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + fs::set_permissions(&settings_path, perms).map_err(ConfigError::Io)?; + } + + Ok(()) +} + +/// Remove the `provider` section from the user-level `~/.claw/settings.json`. +pub fn clear_user_provider_settings() -> Result<(), ConfigError> { + let config_home = default_config_home(); + let settings_path = config_home.join("settings.json"); + + if !settings_path.exists() { + return Ok(()); + } + + let mut root = read_settings_root(&settings_path); + if root.remove("provider").is_none() { + return Ok(()); + } + root.remove("model"); + + write_settings_root(&settings_path, &root)?; + + Ok(()) +} + +fn read_settings_root(path: &Path) -> serde_json::Map { + match fs::read_to_string(path) { + Ok(contents) if !contents.trim().is_empty() => { + serde_json::from_str::(&contents) + .ok() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() + } + _ => serde_json::Map::new(), + } +} + +fn write_settings_root( + path: &Path, + root: &serde_json::Map, +) -> Result<(), ConfigError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(ConfigError::Io)?; + } + let rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone())) + .map_err(|e| ConfigError::Parse(e.to_string()))?; + fs::write(path, format!("{rendered}\n")).map_err(ConfigError::Io) +} + impl RuntimeHookConfig { #[must_use] pub fn new( diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs new file mode 100644 index 0000000000..69fabfb36b --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -0,0 +1,287 @@ +use std::io::{self, IsTerminal, Write}; + +use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig}; + +use serde_json; + +const PROVIDERS: &[(&str, &str, &str)] = &[ + ("1", "Anthropic", "anthropic"), + ("2", "xAI / Grok", "xai"), + ("3", "OpenAI", "openai"), + ("4", "DashScope (Qwen/Kimi)", "dashscope"), + ("5", "Custom (OpenAI-compat)", "openai"), +]; + +const PROVIDER_MODELS: &[(&str, &[&str])] = &[ + ("anthropic", &["opus", "sonnet", "haiku"]), + ("xai", &["grok", "grok-mini", "grok-2"]), + ("openai", &["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]), + ("dashscope", &["qwen-plus", "qwen-max", "kimi"]), +]; + +const DEFAULT_BASE_URLS: &[(&str, &str)] = &[ + ("anthropic", "https://api.anthropic.com"), + ("xai", "https://api.x.ai/v1"), + ("openai", "https://api.openai.com/v1"), + ("dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"), +]; + +const API_KEY_ENV_VARS: &[(&str, &str)] = &[ + ("anthropic", "ANTHROPIC_API_KEY"), + ("xai", "XAI_API_KEY"), + ("openai", "OPENAI_API_KEY"), + ("dashscope", "DASHSCOPE_API_KEY"), +]; + +pub fn run_setup_wizard() -> Result<(), Box> { + if !io::stdin().is_terminal() { + return Err("setup wizard requires an interactive terminal".into()); + } + + let current = load_current_provider_config(); + + println!(); + println!(" \x1b[1mClaw Code Setup Wizard\x1b[0m"); + println!(" Configure your provider, API key, and model."); + println!(" Press Enter to keep current value.\n"); + + let kind = prompt_provider(¤t)?; + let api_key = prompt_api_key(&kind, ¤t)?; + let base_url = prompt_base_url(&kind, ¤t)?; + let model = prompt_model(&kind, ¤t)?; + let fast_model = prompt_fast_model(¤t, model.as_deref())?; + + save_user_provider_settings( + &kind, + &api_key, + base_url.as_deref(), + model.as_deref(), + )?; + + if let Some(fast) = &fast_model { + save_settings_field("subagentModel", fast)?; + } + + println!(); + println!(" \x1b[32mProvider saved to ~/.claw/settings.json\x1b[0m"); + println!(" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.", model.as_deref().unwrap_or(&kind)); + println!(); + + Ok(()) +} + +fn load_current_provider_config() -> RuntimeProviderConfig { + let cwd = std::env::current_dir().unwrap_or_default(); + ConfigLoader::default_for(&cwd) + .load() + .map(|c| c.provider().clone()) + .unwrap_or_default() +} + +fn prompt_provider(current: &RuntimeProviderConfig) -> Result> { + let current_kind = current.kind().unwrap_or("anthropic"); + println!(" \x1b[1mProvider\x1b[0m"); + for (num, label, kind) in PROVIDERS { + let marker = if *kind == current_kind { " (current)" } else { "" }; + println!(" [{num}] {label}{marker}"); + } + let default = PROVIDERS + .iter() + .position(|(_, _, k)| *k == current_kind) + .map_or_else(|| "1".to_string(), |i| (i + 1).to_string()); + + let input = read_line(&format!(" Select provider [{default}]: "))?; + let choice = if input.trim().is_empty() { + default + } else { + input.trim().to_string() + }; + + let kind = PROVIDERS + .iter() + .find(|(num, _, _)| *num == choice) + .map(|(_, _, kind)| *kind) + .ok_or_else(|| format!("invalid provider choice: {choice}"))?; + + Ok(kind.to_string()) +} + +fn prompt_api_key( + kind: &str, + current: &RuntimeProviderConfig, +) -> Result> { + let env_var = API_KEY_ENV_VARS + .iter() + .find(|(k, _)| *k == kind) + .map_or("API_KEY", |(_, v)| *v); + + let current_key = current.api_key(); + let hint = match current_key { + Some(key) if !key.is_empty() => { + let masked = if key.len() > 4 { + format!("****{}", &key[key.len() - 4..]) + } else { + "****".to_string() + }; + format!("[{masked}]") + } + _ => "(none)".to_string(), + }; + + // Check if env var is already set + let env_set = std::env::var(env_var) + .ok() + .is_some_and(|v| !v.is_empty()); + if env_set { + println!(" {env_var} is set in environment (will take priority over stored key)"); + } + + let input = read_line(&format!(" API key ({env_var}) {hint}: "))?; + let key = if input.trim().is_empty() { + current_key.unwrap_or("").to_string() + } else { + input.trim().to_string() + }; + + if key.is_empty() && !env_set { + eprintln!(" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m"); + } + + Ok(key) +} + +fn prompt_base_url( + kind: &str, + current: &RuntimeProviderConfig, +) -> Result, Box> { + let default_url = DEFAULT_BASE_URLS + .iter() + .find(|(k, _)| *k == kind) + .map_or("", |(_, v)| *v); + + let current_url = current.base_url().unwrap_or(default_url); + let display = if current_url.is_empty() { + default_url.to_string() + } else { + current_url.to_string() + }; + + // Check if the relevant env var is already set + let env_var = match kind { + "anthropic" => "ANTHROPIC_BASE_URL", + "xai" => "XAI_BASE_URL", + "openai" => "OPENAI_BASE_URL", + "dashscope" => "DASHSCOPE_BASE_URL", + _ => "BASE_URL", + }; + let env_set = std::env::var(env_var) + .ok() + .is_some_and(|v| !v.is_empty()); + if env_set { + println!(" {env_var} is set in environment (will take priority over stored URL)"); + } + + let input = read_line(&format!(" Base URL [{display}]: "))?; + if input.trim().is_empty() { + if current_url == default_url || current_url.is_empty() { + Ok(None) + } else { + Ok(Some(current_url.to_string())) + } + } else { + Ok(Some(input.trim().to_string())) + } +} + +fn prompt_model( + kind: &str, + current: &RuntimeProviderConfig, +) -> Result, Box> { + let empty: &[&str] = &[]; + let aliases = PROVIDER_MODELS + .iter() + .find(|(k, _)| *k == kind) + .map_or(empty, |(_, models)| *models); + + let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or("")); + + println!(" \x1b[1mModel\x1b[0m"); + if !aliases.is_empty() { + println!(" Common: {}", aliases.join(", ")); + } + println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)"); + + let input = read_line(&format!(" Model [{current_model}]: "))?; + if input.trim().is_empty() { + if current_model.is_empty() { + Ok(None) + } else { + Ok(Some(current_model.to_string())) + } + } else { + Ok(Some(input.trim().to_string())) + } +} + +fn prompt_fast_model( + current: &RuntimeProviderConfig, + main_model: Option<&str>, +) -> Result, Box> { + println!(); + println!(" \x1b[1mFast Model (for Agent subtasks)\x1b[0m"); + println!(" A smaller/cheaper model used by the Agent tool when spawning"); + println!(" Explore, Plan, or Verification sub-agents. This saves tokens"); + println!(" by using a fast model for information-gathering tasks."); + println!(" Press Enter to skip (agents will use your main model)."); + + let current_fast = load_current_settings_field("subagentModel"); + let default_hint = current_fast + .as_deref() + .or(main_model) + .unwrap_or(""); + + let input = read_line(&format!(" Fast model [{}]: ", if default_hint.is_empty() { "same as main" } else { default_hint }))?; + if input.trim().is_empty() { + Ok(current_fast) + } else { + Ok(Some(input.trim().to_string())) + } +} + +fn load_current_settings_field(field: &str) -> Option { + let home = std::env::var("HOME").ok()?; + let settings_path = std::path::Path::new(&home).join(".claw/settings.json"); + let content = std::fs::read_to_string(&settings_path).ok()?; + let json: serde_json::Value = serde_json::from_str(&content).ok()?; + json.get(field)?.as_str().map(|s| s.to_string()) +} + +fn save_settings_field(field: &str, value: &str) -> Result<(), Box> { + let home = std::env::var("HOME")?; + let settings_dir = std::path::Path::new(&home).join(".claw"); + let settings_path = settings_dir.join("settings.json"); + + let mut settings: serde_json::Value = if settings_path.exists() { + let content = std::fs::read_to_string(&settings_path)?; + serde_json::from_str(&content)? + } else { + serde_json::json!({}) + }; + + if let Some(obj) = settings.as_object_mut() { + obj.insert(field.to_string(), serde_json::Value::String(value.to_string())); + } + + std::fs::create_dir_all(&settings_dir)?; + std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + Ok(()) +} + +fn read_line(prompt: &str) -> Result> { + let mut stdout = io::stdout(); + write!(stdout, "{prompt}")?; + stdout.flush()?; + let mut buffer = String::new(); + io::stdin().read_line(&mut buffer)?; + Ok(buffer) +} 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"