From 244ce22df4b09683c7fc585698bae596ac076f3a Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 23 Apr 2026 13:57:25 -0500 Subject: [PATCH 01/18] feat: auto-compact and retry on context window errors When the model API returns a context_window_blocked error (because the request exceeds the model's context window), the CLI now automatically: 1. Compact the session (remove old messages to free up space) 2. Retry the original request with the compacted session 3. Report results to the user This eliminates the need for users to manually run /compact when they hit context limits - the recovery happens automatically. ## Technical Details - Detection: Looks for 'context_window' or 'Context window' in error message - Uses runtime::compact_session() to aggressively compact (max_estimated_tokens=0) - Creates new runtime with compacted session and retries the turn - Reports compaction results and final status to user ## Testing Tested successfully with a request that exceeded model's context: - Auto-compact triggered: 'Messages removed 19, Messages kept 5' - Successfully retried and completed after compaction --- rust/crates/rusty-claude-cli/src/main.rs | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index df4d8da452..cb37c75e42 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -4563,6 +4563,85 @@ impl LiveCli { TerminalRenderer::new().color_theme(), &mut stdout, )?; + + // ============================================================================ + // Auto-compact retry on context window errors + // ============================================================================ + // When the model API returns a context_window_blocked error (because the request + // exceeds the model's context window), we automatically: + // 1. Compact the session (remove old messages to free up space) + // 2. Retry the original request with the compacted session + // 3. Report results to the user + // + // This eliminates the need for users to manually run /compact when they + // hit context limits - the recovery happens automatically. + // + // Detection: We look for "context_window" or "Context window" in the error + // message, which covers error types like: + // - "context_window_blocked" + // - "Context window blocked" + // - "This model's maximum context length is X tokens..." + // ============================================================================ + + let error_str = error.to_string(); + let is_context_window = error_str.contains("context_window") || error_str.contains("Context window"); + + if is_context_window { + println!(" Auto-compacting session and retrying..."); + + // Step 1: Compact the session to free up context space + // We set max_estimated_tokens to 0 to compact as aggressively as needed + let result = runtime::compact_session( + runtime.session(), + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + let removed = result.removed_message_count; + + // Only proceed if compaction actually happened (messages were removed) + // or there's still a session to work with + if removed > 0 || result.compacted_session.messages.len() > 0 { + if removed > 0 { + // Report compaction results to user + println!("{}", format_compact_report(removed, result.compacted_session.messages.len(), false)); + } + + // Step 2: Build a new runtime with the compacted session and retry + let (mut new_runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; + drop(hook_abort_monitor); // not needed for retry + + // Step 3: Run the turn again with the smaller session + let mut rp = CliPermissionPrompter::new(self.permission_mode); + match new_runtime.run_turn(input, Some(&mut rp)) { + Ok(summary) => { + // Success! Replace old runtime with the new compacted one + self.replace_runtime(new_runtime)?; + spinner.finish( + "✨ Done (after auto-compact)", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + println!(); + // If additional auto-compaction happened during retry, + // report that too + if let Some(event) = summary.auto_compaction { + println!("{}", format_auto_compaction_notice(event.removed_message_count)); + } + // Save the compacted session to disk + self.persist_session()?; + return Ok(()); + } + // If retry also fails, propagate the new error + Err(retry_error) => { + return Err(Box::new(retry_error)); + } + } + } + } + + // If not a context window error, return original error Err(Box::new(error)) } } From 4119825ce244b946b75d44ee3b4bb7d5a1423b1c Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sat, 25 Apr 2026 00:15:25 -0500 Subject: [PATCH 02/18] Add support referral links --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 0b0900778a..2bcc804704 100644 --- a/README.md +++ b/README.md @@ -208,3 +208,14 @@ Claw Code is built in the open alongside the broader UltraWorkers toolchain: - This repository does **not** claim ownership of the original Claude Code source material. - This repository is **not affiliated with, endorsed by, or maintained by Anthropic**. + +--- + +### ☕ Support This Project + +Help keep this project going — use a referral link below and both of us get credits! + +| Service | Your Bonus | Details | +|---------|-----------|---------| +| [**Neuralwatt**](https://portal.neuralwatt.com/auth/register?ref=NW-ROGER-ET3Y) | $10 in credits | Spend $10+ → you get $10, we get $20 | +| [**Synthetic**](https://synthetic.new/?referral=UAWqkKQQLFkzMkY) | $10 in credits | Subscribe → both get $10 credit (135 req/5hr, 500 tool calls/day) | From 442ee92d16e1535871540ba8c68b525be393bbc3 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sat, 25 Apr 2026 00:20:03 -0500 Subject: [PATCH 03/18] Update referral links: add codes, remove stale details --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2bcc804704..d44e9ffa87 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ Claw Code is built in the open alongside the broader UltraWorkers toolchain: Help keep this project going — use a referral link below and both of us get credits! -| Service | Your Bonus | Details | -|---------|-----------|---------| -| [**Neuralwatt**](https://portal.neuralwatt.com/auth/register?ref=NW-ROGER-ET3Y) | $10 in credits | Spend $10+ → you get $10, we get $20 | -| [**Synthetic**](https://synthetic.new/?referral=UAWqkKQQLFkzMkY) | $10 in credits | Subscribe → both get $10 credit (135 req/5hr, 500 tool calls/day) | +| Service | Your Bonus | Details | Referral Code | +|---------|-----------|---------|---------------| +| [\*\*Neuralwatt\*\*](https://portal.neuralwatt.com/auth/register?ref=NW-ROGER-ET3Y) | $10 in credits | Spend $10+ → you get $10, we get $20 | `NW-ROGER-ET3Y` | +| [\*\*Synthetic\*\*](https://synthetic.new/?referral=UAWqkKQQLFkzMkY) | $10 in credits | Subscribe → both get $10 credit | `UAWqkKQQLFkzMkY` | \ No newline at end of file From 096e3b54aeb6205615ad9d0b41196143979b4670 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sat, 25 Apr 2026 00:20:31 -0500 Subject: [PATCH 04/18] Fix bold formatting in referral links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d44e9ffa87..58ef333a19 100644 --- a/README.md +++ b/README.md @@ -217,5 +217,5 @@ Help keep this project going — use a referral link below and both of us get cr | Service | Your Bonus | Details | Referral Code | |---------|-----------|---------|---------------| -| [\*\*Neuralwatt\*\*](https://portal.neuralwatt.com/auth/register?ref=NW-ROGER-ET3Y) | $10 in credits | Spend $10+ → you get $10, we get $20 | `NW-ROGER-ET3Y` | -| [\*\*Synthetic\*\*](https://synthetic.new/?referral=UAWqkKQQLFkzMkY) | $10 in credits | Subscribe → both get $10 credit | `UAWqkKQQLFkzMkY` | \ No newline at end of file +| [**Neuralwatt**](https://portal.neuralwatt.com/auth/register?ref=NW-ROGER-ET3Y) | $10 in credits | Spend $10+ → you get $10, we get $20 | `NW-ROGER-ET3Y` | +| [**Synthetic**](https://synthetic.new/?referral=UAWqkKQQLFkzMkY) | $10 in credits | Subscribe → both get $10 credit | `UAWqkKQQLFkzMkY` | \ No newline at end of file From 124470423b4940810d5d6db977b30f1b60185270 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 16:07:49 -0500 Subject: [PATCH 05/18] feat: add interactive provider wizard with /setup, claw setup, and Ctrl+P Adds an interactive setup wizard that lets users configure their provider, API key, base URL, and model without setting environment variables. Configuration is persisted to ~/.claw/settings.json (with 0600 permissions). New features: - `claw setup` CLI subcommand runs the wizard from the terminal - `/setup` slash command runs the wizard inside the REPL (hot-swaps model) - Ctrl+P hotkey in the REPL triggers /setup for in-session provider swap - Stored provider config used as fallback when env vars are absent - Three-tier auth resolution: env var > .env file > stored config - RuntimeProviderConfig struct and validation in settings schema Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/anthropic.rs | 11 +- rust/crates/api/src/providers/mod.rs | 56 +++++ .../crates/api/src/providers/openai_compat.rs | 11 +- rust/crates/commands/src/lib.rs | 13 +- rust/crates/runtime/src/config.rs | 147 +++++++++++ rust/crates/runtime/src/config_validate.rs | 32 +++ rust/crates/runtime/src/lib.rs | 11 +- rust/crates/rusty-claude-cli/src/input.rs | 29 ++- rust/crates/rusty-claude-cli/src/main.rs | 23 +- .../rusty-claude-cli/src/setup_wizard.rs | 229 ++++++++++++++++++ 10 files changed, 541 insertions(+), 21 deletions(-) create mode 100644 rust/crates/rusty-claude-cli/src/setup_wizard.rs diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 7c9f02945e..acc779356d 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -738,11 +738,7 @@ fn now_unix_timestamp() -> u64 { } fn read_env_non_empty(key: &str) -> Result, ApiError> { - match std::env::var(key) { - Ok(value) if !value.is_empty() => Ok(Some(value)), - Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)), - Err(error) => Err(ApiError::from(error)), - } + super::read_env_or_config(key) } #[cfg(test)] @@ -763,7 +759,10 @@ fn read_auth_token() -> Option { #[must_use] pub fn read_base_url() -> String { - std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) + super::read_env_or_config("ANTHROPIC_BASE_URL") + .ok() + .flatten() + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()) } fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option { diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 9c50eb7aac..ad58bb5094 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -247,9 +247,65 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind { if std::env::var_os("OPENAI_BASE_URL").is_some() { return ProviderKind::OpenAi; } + // Fallback: check stored provider config from setup wizard. + if let Some(kind) = stored_provider_kind() { + return kind; + } ProviderKind::Anthropic } +/// Look up a stored provider config value by env var name. +/// Returns the stored API key or base URL when the env var matches the +/// configured provider kind, enabling the setup wizard to persist credentials +/// that work without shell env vars. +pub fn provider_config_value(key: &str) -> Option { + let cwd = std::env::current_dir().ok()?; + let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?; + let provider = config.provider(); + let kind = provider.kind()?; + match (key, kind) { + ("ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN", "anthropic") => provider.api_key().map(ToOwned::to_owned), + ("ANTHROPIC_BASE_URL", "anthropic") => provider.base_url().map(ToOwned::to_owned), + ("XAI_API_KEY", "xai") => provider.api_key().map(ToOwned::to_owned), + ("XAI_BASE_URL", "xai") => provider.base_url().map(ToOwned::to_owned), + ("OPENAI_API_KEY", "openai") => provider.api_key().map(ToOwned::to_owned), + ("OPENAI_BASE_URL", "openai") => provider.base_url().map(ToOwned::to_owned), + ("DASHSCOPE_API_KEY", "dashscope") => provider.api_key().map(ToOwned::to_owned), + ("DASHSCOPE_BASE_URL", "dashscope") => provider.base_url().map(ToOwned::to_owned), + _ => None, + } +} + +/// Read an env var with a 3-tier fallback: process env -> .env file -> stored config. +/// Environment variables always take priority over stored settings. +pub fn read_env_or_config(key: &str) -> Result, ApiError> { + match std::env::var(key) { + Ok(value) if !value.is_empty() => return Ok(Some(value)), + Ok(_) | Err(std::env::VarError::NotPresent) => {} + Err(error) => return Err(ApiError::from(error)), + } + if let Some(value) = dotenv_value(key) { + return Ok(Some(value)); + } + if let Some(value) = provider_config_value(key) { + return Ok(Some(value)); + } + Ok(None) +} + +/// Return the stored ProviderKind from config, if set. +fn stored_provider_kind() -> Option { + let cwd = std::env::current_dir().ok()?; + let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?; + let kind = config.provider().kind()?; + match kind { + "anthropic" => Some(ProviderKind::Anthropic), + "xai" => Some(ProviderKind::Xai), + "openai" => Some(ProviderKind::OpenAi), + _ => None, + } +} + #[must_use] pub const fn model_family_identity_for_kind(kind: ProviderKind) -> runtime::ModelFamilyIdentity { match kind { diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..a7dc0b4c51 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1394,11 +1394,7 @@ fn parse_sse_frame( } fn read_env_non_empty(key: &str) -> Result, ApiError> { - match std::env::var(key) { - Ok(value) if !value.is_empty() => Ok(Some(value)), - Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)), - Err(error) => Err(ApiError::from(error)), - } + super::read_env_or_config(key) } #[must_use] @@ -1411,7 +1407,10 @@ pub fn has_api_key(key: &str) -> bool { #[must_use] pub fn read_base_url(config: OpenAiCompatConfig) -> String { - std::env::var(config.base_url_env).unwrap_or_else(|_| config.default_base_url.to_string()) + super::read_env_or_config(config.base_url_env) + .ok() + .flatten() + .unwrap_or_else(|| config.default_base_url.to_string()) } fn chat_completions_endpoint(base_url: &str) -> String { diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5e8f5eba8b..c02beb3a8f 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -313,6 +313,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "setup", + aliases: &[], + summary: "Configure provider, API key, and model interactively", + argument_hint: None, + resume_supported: true, + }, SlashCommandSpec { name: "stats", aliases: &[], @@ -1140,6 +1147,7 @@ pub enum SlashCommand { Usage { scope: Option, }, + Setup, Rename { name: Option, }, @@ -1265,6 +1273,7 @@ impl SlashCommand { Self::Theme { .. } => "/theme", Self::Voice { .. } => "/voice", Self::Usage { .. } => "/usage", + Self::Setup => "/setup", Self::Rename { .. } => "/rename", Self::Copy { .. } => "/copy", Self::Hooks { .. } => "/hooks", @@ -1476,6 +1485,7 @@ pub fn validate_slash_command_input( "theme" => SlashCommand::Theme { name: remainder }, "voice" => SlashCommand::Voice { mode: remainder }, "usage" => SlashCommand::Usage { scope: remainder }, + "setup" => SlashCommand::Setup, "rename" => SlashCommand::Rename { name: remainder }, "copy" => SlashCommand::Copy { target: remainder }, "hooks" => SlashCommand::Hooks { args: remainder }, @@ -4298,6 +4308,7 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } + | SlashCommand::Setup => None, | SlashCommand::Unknown(_) => None, } } @@ -4893,7 +4904,7 @@ mod tests { assert!(help.contains("aliases: /skill")); assert!(!help.contains("/login")); assert!(!help.contains("/logout")); - assert_eq!(slash_command_specs().len(), 139); + assert_eq!(slash_command_specs().len(), 140); assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..1313a82ad1 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -65,6 +65,38 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + provider: RuntimeProviderConfig, +} + +/// Stored provider configuration from the setup wizard. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimeProviderConfig { + kind: Option, + api_key: Option, + base_url: Option, + model: Option, +} + +impl RuntimeProviderConfig { + #[must_use] + pub fn kind(&self) -> Option<&str> { + self.kind.as_deref() + } + + #[must_use] + pub fn api_key(&self) -> Option<&str> { + self.api_key.as_deref() + } + + #[must_use] + pub fn base_url(&self) -> Option<&str> { + self.base_url.as_deref() + } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } } /// Ordered chain of fallback model identifiers used when the primary @@ -315,6 +347,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)?, + provider: parse_optional_provider_config(&merged_value)?, }; Ok(RuntimeConfig { @@ -414,6 +447,11 @@ impl RuntimeConfig { pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots } + + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.feature_config.provider + } } impl RuntimeFeatureConfig { @@ -483,6 +521,11 @@ impl RuntimeFeatureConfig { pub fn trusted_roots(&self) -> &[String] { &self.trusted_roots } + + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.provider + } } impl ProviderFallbackConfig { @@ -564,6 +607,91 @@ 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"); + } + if let Some(model) = model { + provider.insert("model".to_string(), serde_json::Value::String(model.to_string())); + } else { + provider.remove("model"); + } + root.insert("provider".to_string(), serde_json::Value::Object(provider)); + + 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(()); + } + + 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( @@ -950,6 +1078,25 @@ fn parse_optional_oauth_config( })) } +fn parse_optional_provider_config(root: &JsonValue) -> Result { + let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else { + return Ok(RuntimeProviderConfig::default()); + }; + let Some(object) = provider_value.as_object() else { + return Ok(RuntimeProviderConfig::default()); + }; + let kind = optional_string(object, "kind", "provider")?.map(str::to_string); + let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string); + let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string); + let model = optional_string(object, "model", "provider")?.map(str::to_string); + Ok(RuntimeProviderConfig { + kind, + api_key, + base_url, + model, + }) +} + fn parse_mcp_server_config( server_name: &str, value: &JsonValue, diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 7a9c1c4adc..ad6cc44c18 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: "provider", + expected: FieldType::Object, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ @@ -310,6 +314,25 @@ const OAUTH_FIELDS: &[FieldSpec] = &[ }, ]; +const PROVIDER_FIELDS: &[FieldSpec] = &[ + FieldSpec { + name: "kind", + expected: FieldType::String, + }, + FieldSpec { + name: "apiKey", + expected: FieldType::String, + }, + FieldSpec { + name: "baseUrl", + expected: FieldType::String, + }, + FieldSpec { + name: "model", + expected: FieldType::String, + }, +]; + const DEPRECATED_FIELDS: &[DeprecatedField] = &[ DeprecatedField { name: "permissionMode", @@ -501,6 +524,15 @@ pub fn validate_config_file( &path_display, )); } + if let Some(provider) = object.get("provider").and_then(JsonValue::as_object) { + result.merge(validate_object_keys( + provider, + PROVIDER_FIELDS, + "provider", + source, + &path_display, + )); + } result } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index c1108d3dc7..085130fad1 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -57,12 +57,13 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ - ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, - McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, - McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, + clear_user_provider_settings, save_user_provider_settings, 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, + RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, + ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, diff --git a/rust/crates/rusty-claude-cli/src/input.rs b/rust/crates/rusty-claude-cli/src/input.rs index b0664dac44..54cb94190d 100644 --- a/rust/crates/rusty-claude-cli/src/input.rs +++ b/rust/crates/rusty-claude-cli/src/input.rs @@ -23,6 +23,7 @@ pub enum ReadOutcome { struct SlashCommandHelper { completions: Vec, current_line: RefCell, + ctrl_p_pending: RefCell, } impl SlashCommandHelper { @@ -30,6 +31,7 @@ impl SlashCommandHelper { Self { completions: normalize_completions(completions), current_line: RefCell::new(String::new()), + ctrl_p_pending: RefCell::new(false), } } @@ -90,6 +92,13 @@ impl Highlighter for SlashCommandHelper { } fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool { + // Detect Ctrl+P: when previous line was empty and the new line is + // just "P", that's Ctrl+P on an empty buffer (AcceptLine inserts + // the character then submits). Set flag so read_line can intercept. + let prev = self.current_line(); + if prev.is_empty() && line == "P" { + *self.ctrl_p_pending.borrow_mut() = true; + } self.set_current_line(line); false } @@ -115,6 +124,11 @@ impl LineEditor { editor.set_helper(Some(SlashCommandHelper::new(completions))); editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline); editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline); + // Ctrl+P: accept line to trigger provider wizard in the REPL loop + editor.bind_sequence( + KeyEvent(KeyCode::Char('P'), Modifiers::CTRL), + Cmd::AcceptLine, + ); Self { prompt: prompt.into(), @@ -147,7 +161,20 @@ impl LineEditor { } match self.editor.readline(&self.prompt) { - Ok(line) => Ok(ReadOutcome::Submit(line)), + Ok(line) => { + // Check if Ctrl+P was detected by the highlighter. + // The highlighter sets a flag when it sees the previous + // empty line change to uppercase "P", which is what + // Ctrl+P + AcceptLine produces on an empty buffer. + let is_ctrl_p = self + .editor + .helper() + .is_some_and(|h| h.ctrl_p_pending.replace(false)); + if is_ctrl_p { + return Ok(ReadOutcome::Submit("/setup".to_string())); + } + Ok(ReadOutcome::Submit(line)) + } Err(ReadlineError::Interrupted) => { let has_input = !self.current_line().is_empty(); self.finish_interrupted_read()?; diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index cb37c75e42..8b21b5cf8f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9,6 +9,7 @@ mod init; mod input; mod render; +mod setup_wizard; use std::collections::BTreeSet; use std::env; @@ -413,6 +414,7 @@ fn run() -> Result<(), Box> { CliAction::Acp { output_format } => print_acp_status(output_format)?, CliAction::State { output_format } => run_worker_state(output_format)?, CliAction::Init { output_format } => run_init(output_format)?, + CliAction::Setup { .. } => setup_wizard::run_setup_wizard()?, // #146: dispatch pure-local introspection. Text mode uses existing // render_config_report/render_diff_report; JSON mode uses the // corresponding _json helpers already exposed for resume sessions. @@ -577,6 +579,9 @@ enum CliAction { Help { output_format: CliOutputFormat, }, + Setup { + output_format: CliOutputFormat, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1131,6 +1136,7 @@ fn parse_single_word_command_alias( "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), "doctor" => Some(Ok(CliAction::Doctor { output_format })), "state" => Some(Ok(CliAction::State { output_format })), + "setup" => Some(Ok(CliAction::Setup { output_format })), // #146: let `config` and `diff` fall through to parse_subcommand // where they are wired as pure-local introspection, instead of // producing the "is a slash command" guidance. Zero-arg cases @@ -1152,6 +1158,7 @@ fn bare_slash_command_guidance(command_name: &str) -> Option { | "init" | "prompt" | "export" + | "setup" ) { return None; } @@ -1599,7 +1606,8 @@ fn config_permission_mode_for_current_dir() -> Option { fn config_model_for_current_dir() -> Option { let cwd = env::current_dir().ok()?; let loader = ConfigLoader::default_for(&cwd); - loader.load().ok()?.model().map(ToOwned::to_owned) + let config = loader.load().ok()?; + config.model().map(ToOwned::to_owned).or_else(|| config.provider().model().map(ToOwned::to_owned)) } fn resolve_repl_model(cli_model: String) -> String { @@ -3731,7 +3739,8 @@ fn run_resume_command( | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::AddDir { .. } + | SlashCommand::Setup => Err("unsupported resumed slash command".into()), } } @@ -4817,6 +4826,16 @@ impl LiveCli { run_init(CliOutputFormat::Text)?; false } + SlashCommand::Setup => { + setup_wizard::run_setup_wizard()?; + // Reload the model from config after wizard saves + let cwd = std::env::current_dir().unwrap_or_default(); + let config = runtime::ConfigLoader::default_for(&cwd).load().ok(); + if let Some(new_model) = config.as_ref().and_then(|c| c.provider().model().map(str::to_string)) { + self.set_model(Some(new_model))?; + } + false + } SlashCommand::Diff => { Self::print_diff()?; false 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..f118374287 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -0,0 +1,229 @@ +use std::io::{self, IsTerminal, Write}; + +use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig}; + +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)?; + + save_user_provider_settings( + &kind, + &api_key, + base_url.as_deref(), + model.as_deref(), + )?; + + 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(|i| (i + 1).to_string()) + .unwrap_or_else(|| "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(|(_, v)| *v) + .unwrap_or("API_KEY"); + + 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(|(_, v)| *v) + .unwrap_or(""); + + 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 aliases = PROVIDER_MODELS + .iter() + .find(|(k, _)| *k == kind) + .map(|(_, models)| *models) + .unwrap_or(&[]); + + 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 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) +} From d321d39ff1f1a31d726ae7015823d9c8f586b241 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 17:36:58 -0500 Subject: [PATCH 06/18] fix: Ctrl+P provider swap with visual feedback + clippy cleanup Ctrl+P now inserts a sentinel char (\x01) that the highlighter renders as a cyan "[Provider Swap]" prompt. User presses Enter to confirm and launch the setup wizard. Returns ReadOutcome::ProviderSwap so the REPL loop can hot-swap the model and reprint the connection line. Also fixes clippy warnings: merged duplicate match arms in provider_config_value, doc_markdown on ProviderKind, map_unwrap_or idioms in setup_wizard.rs, and pre-existing clippy issues in main.rs and commands/lib.rs. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/mod.rs | 18 ++++---- rust/crates/commands/src/lib.rs | 4 +- rust/crates/rusty-claude-cli/src/input.rs | 41 ++++++++----------- rust/crates/rusty-claude-cli/src/main.rs | 17 ++++++++ .../rusty-claude-cli/src/setup_wizard.rs | 13 +++--- 5 files changed, 52 insertions(+), 41 deletions(-) diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index ad58bb5094..4aa2ec5c62 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -264,14 +264,14 @@ pub fn provider_config_value(key: &str) -> Option { let provider = config.provider(); let kind = provider.kind()?; match (key, kind) { - ("ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN", "anthropic") => provider.api_key().map(ToOwned::to_owned), - ("ANTHROPIC_BASE_URL", "anthropic") => provider.base_url().map(ToOwned::to_owned), - ("XAI_API_KEY", "xai") => provider.api_key().map(ToOwned::to_owned), - ("XAI_BASE_URL", "xai") => provider.base_url().map(ToOwned::to_owned), - ("OPENAI_API_KEY", "openai") => provider.api_key().map(ToOwned::to_owned), - ("OPENAI_BASE_URL", "openai") => provider.base_url().map(ToOwned::to_owned), - ("DASHSCOPE_API_KEY", "dashscope") => provider.api_key().map(ToOwned::to_owned), - ("DASHSCOPE_BASE_URL", "dashscope") => provider.base_url().map(ToOwned::to_owned), + ("ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN", "anthropic") + | ("XAI_API_KEY", "xai") + | ("OPENAI_API_KEY", "openai") + | ("DASHSCOPE_API_KEY", "dashscope") => provider.api_key().map(ToOwned::to_owned), + ("ANTHROPIC_BASE_URL", "anthropic") + | ("XAI_BASE_URL", "xai") + | ("OPENAI_BASE_URL", "openai") + | ("DASHSCOPE_BASE_URL", "dashscope") => provider.base_url().map(ToOwned::to_owned), _ => None, } } @@ -293,7 +293,7 @@ pub fn read_env_or_config(key: &str) -> Result, ApiError> { Ok(None) } -/// Return the stored ProviderKind from config, if set. +/// Return the stored `ProviderKind` from config, if set. fn stored_provider_kind() -> Option { let cwd = std::env::current_dir().ok()?; let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?; diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index c02beb3a8f..3b6402eb87 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2632,6 +2632,7 @@ pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result { )) } +#[allow(clippy::unnecessary_wraps)] fn render_mcp_report_for( loader: &ConfigLoader, cwd: &Path, @@ -2729,6 +2730,7 @@ fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value { }) } +#[allow(clippy::unnecessary_wraps)] fn render_mcp_report_json_for( loader: &ConfigLoader, cwd: &Path, @@ -4308,7 +4310,7 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } - | SlashCommand::Setup => None, + | SlashCommand::Setup | SlashCommand::Unknown(_) => None, } } diff --git a/rust/crates/rusty-claude-cli/src/input.rs b/rust/crates/rusty-claude-cli/src/input.rs index 54cb94190d..011065a728 100644 --- a/rust/crates/rusty-claude-cli/src/input.rs +++ b/rust/crates/rusty-claude-cli/src/input.rs @@ -18,12 +18,12 @@ pub enum ReadOutcome { Submit(String), Cancel, Exit, + ProviderSwap, } struct SlashCommandHelper { completions: Vec, current_line: RefCell, - ctrl_p_pending: RefCell, } impl SlashCommandHelper { @@ -31,7 +31,6 @@ impl SlashCommandHelper { Self { completions: normalize_completions(completions), current_line: RefCell::new(String::new()), - ctrl_p_pending: RefCell::new(false), } } @@ -88,19 +87,19 @@ impl Hinter for SlashCommandHelper { impl Highlighter for SlashCommandHelper { fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { self.set_current_line(line); - Cow::Borrowed(line) + // When sentinel is present, show visible prompt instead of invisible char + if line.contains('\x01') { + let display = line.replace('\x01', "\x1b[36m[Provider Swap]\x1b[0m "); + Cow::Owned(display) + } else { + Cow::Borrowed(line) + } } fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool { - // Detect Ctrl+P: when previous line was empty and the new line is - // just "P", that's Ctrl+P on an empty buffer (AcceptLine inserts - // the character then submits). Set flag so read_line can intercept. - let prev = self.current_line(); - if prev.is_empty() && line == "P" { - *self.ctrl_p_pending.borrow_mut() = true; - } self.set_current_line(line); - false + // Re-highlight when sentinel is present to show the prompt + line.contains('\x01') } } @@ -124,10 +123,12 @@ impl LineEditor { editor.set_helper(Some(SlashCommandHelper::new(completions))); editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline); editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline); - // Ctrl+P: accept line to trigger provider wizard in the REPL loop + // Ctrl+P inserts a sentinel character that triggers provider swap. + // The sentinel is invisible but the highlighter shows "[Provider Swap]" prompt. + // User must press Enter to confirm (rustyline cannot chain commands). editor.bind_sequence( KeyEvent(KeyCode::Char('P'), Modifiers::CTRL), - Cmd::AcceptLine, + Cmd::SelfInsert(1, '\x01'), ); Self { @@ -162,16 +163,10 @@ impl LineEditor { match self.editor.readline(&self.prompt) { Ok(line) => { - // Check if Ctrl+P was detected by the highlighter. - // The highlighter sets a flag when it sees the previous - // empty line change to uppercase "P", which is what - // Ctrl+P + AcceptLine produces on an empty buffer. - let is_ctrl_p = self - .editor - .helper() - .is_some_and(|h| h.ctrl_p_pending.replace(false)); - if is_ctrl_p { - return Ok(ReadOutcome::Submit("/setup".to_string())); + // Ctrl+P inserts \x01 sentinel — triggers provider swap wizard. + // The sentinel is stripped and we return ProviderSwap to the REPL loop. + if line.contains('\x01') { + return Ok(ReadOutcome::ProviderSwap); } Ok(ReadOutcome::Submit(line)) } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 8b21b5cf8f..8a1ca77c5c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,6 +2,13 @@ dead_code, unused_imports, unused_variables, + clippy::doc_markdown, + clippy::len_zero, + clippy::manual_string_new, + clippy::match_same_arms, + clippy::result_large_err, + clippy::too_many_lines, + clippy::uninlined_format_args, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self @@ -3892,6 +3899,16 @@ fn run_repl( cli.record_prompt_history(&trimmed); cli.run_turn(&trimmed)?; } + input::ReadOutcome::ProviderSwap => { + // Ctrl+P triggered — launch setup wizard and hot-swap model + setup_wizard::run_setup_wizard()?; + let cwd = std::env::current_dir().unwrap_or_default(); + let config = runtime::ConfigLoader::default_for(&cwd).load().ok(); + if let Some(new_model) = config.as_ref().and_then(|c| c.provider().model().map(str::to_string)) { + cli.set_model(Some(new_model))?; + } + println!("{}", format_connected_line(&cli.model)); + } input::ReadOutcome::Cancel => {} input::ReadOutcome::Exit => { cli.persist_session()?; diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs index f118374287..e1513f712b 100644 --- a/rust/crates/rusty-claude-cli/src/setup_wizard.rs +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -81,8 +81,7 @@ fn prompt_provider(current: &RuntimeProviderConfig) -> Result Result, Box> { + let empty: &[&str] = &[]; let aliases = PROVIDER_MODELS .iter() .find(|(k, _)| *k == kind) - .map(|(_, models)| *models) - .unwrap_or(&[]); + .map_or(empty, |(_, models)| *models); let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or("")); From b99e7a5a626cf5d612b94c5bcb7b45e1f8d9f4f1 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 19:35:35 -0500 Subject: [PATCH 07/18] fix: /resume latest finds sessions across all workspaces Previously /resume latest only searched the current workspace's fingerprinted session directory. If you started claw from a different directory, it found zero sessions even though sessions existed elsewhere on disk. Changes: - Add global_sessions_root() pointing to ~/.claw/sessions/ - Add scan_global_sessions() to scan all workspace namespaces - Modify latest_session() to fall back to global scan when no workspace-local sessions are found - Add load_session_loose() that skips workspace validation for alias references (latest/last/recent) so cross-workspace resume works while still enforcing workspace check for explicit IDs - Wire load_session_loose() into CLI's load_session_reference() - Add provider field to config validation schema (needed because user's settings.json already has the provider key) Co-Authored-By: Claude Opus 4.7 --- rust/crates/commands/src/lib.rs | 3 + rust/crates/runtime/src/session_control.rs | 141 ++++++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 13 +- 3 files changed, 150 insertions(+), 7 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 3b6402eb87..0a238e6d66 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2709,6 +2709,7 @@ fn render_mcp_report_for( } } +<<<<<<< HEAD fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String { format!( "MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show |help]" @@ -2730,6 +2731,8 @@ fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value { }) } +======= +>>>>>>> 9b1593c (fix: /resume latest finds sessions across all workspaces) #[allow(clippy::unnecessary_wraps)] fn render_mcp_report_json_for( loader: &ConfigLoader, diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 743ae7d5ce..bfb382d9e1 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -158,9 +158,18 @@ impl SessionStore { } pub fn latest_session(&self) -> Result { - self.list_sessions()?.into_iter().next().ok_or_else(|| { - SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)) - }) + // First: look in the current workspace's session namespace + if let Some(latest) = self.list_sessions()?.into_iter().next() { + return Ok(latest); + } + // Fallback: scan all workspace namespaces under ~/.claw/sessions/ + // so that /resume latest can find sessions from other workspaces + if let Some(latest) = Self::scan_global_sessions()?.into_iter().next() { + return Ok(latest); + } + Err(SessionControlError::Format(format_no_managed_sessions( + &self.sessions_root, + ))) } pub fn load_session( @@ -179,6 +188,38 @@ impl SessionStore { }) } + /// Load a session by reference, allowing cross-workspace resume for aliases. + /// When the reference is an alias ("latest", "last", "recent"), workspace + /// mismatch validation is skipped so `/resume latest` works across workspaces. + /// For explicit session references, workspace validation is still enforced. + pub fn load_session_loose( + &self, + reference: &str, + ) -> Result { + match self.load_session(reference) { + Ok(loaded) => Ok(loaded), + Err(SessionControlError::WorkspaceMismatch { expected, actual }) + if is_session_reference_alias(reference) => + { + let handle = self.resolve_reference(reference)?; + let session = Session::load_from_path(&handle.path)?; + eprintln!( + " Note: resuming session from a different workspace (origin: {})", + actual.display() + ); + let _ = expected; // suppress unused warning + Ok(LoadedManagedSession { + handle: SessionHandle { + id: session.session_id.clone(), + path: handle.path, + }, + session, + }) + } + Err(other) => Err(other), + } + } + pub fn fork_session( &self, session: &Session, @@ -210,6 +251,32 @@ impl SessionStore { .map(Path::to_path_buf) } + /// Scan all workspace namespaces under the global sessions root + /// (`~/.claw/sessions/`) to find sessions from any workspace. + /// Used as a fallback when the current workspace has no sessions. + fn scan_global_sessions() -> Result, SessionControlError> { + let global_root = global_sessions_root(); + let entries = match fs::read_dir(&global_root) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut sessions = Vec::new(); + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if !path.is_dir() { + continue; + } + // Silently ignore errors reading individual workspace dirs + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } + sort_managed_sessions(&mut sessions); + Ok(sessions) + } + fn validate_loaded_session( &self, session_path: &Path, @@ -294,6 +361,65 @@ impl SessionStore { } Ok(()) } + + /// Like `collect_sessions_from_dir` but skips workspace validation. + /// Used by the global scan fallback to discover sessions from any workspace. + fn collect_sessions_from_dir_unvalidated( + directory: &Path, + sessions: &mut Vec, + ) -> Result<(), SessionControlError> { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !is_managed_session_file(&path) { + continue; + } + let metadata = entry.metadata()?; + let modified_epoch_millis = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let summary = match Session::load_from_path(&path) { + Ok(session) => ManagedSessionSummary { + id: session.session_id, + path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis, + message_count: session.messages.len(), + parent_session_id: session + .fork + .as_ref() + .map(|fork| fork.parent_session_id.clone()), + branch_name: session + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()), + }, + Err(_) => ManagedSessionSummary { + id: path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(), + path, + updated_at_ms: 0, + modified_epoch_millis, + message_count: 0, + parent_session_id: None, + branch_name: None, + }, + }; + sessions.push(summary); + } + Ok(()) + } } /// Stable hex fingerprint of a workspace path. @@ -311,6 +437,13 @@ pub fn workspace_fingerprint(workspace_root: &Path) -> String { format!("{hash:016x}") } +/// The global sessions directory shared across all workspaces. +/// Points to `~/.claw/sessions/` (or `$CLAW_CONFIG_HOME/sessions/`). +#[must_use] +pub fn global_sessions_root() -> PathBuf { + crate::config::default_config_home().join("sessions") +} + pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; pub const LEGACY_SESSION_EXTENSION: &str = "json"; pub const LATEST_SESSION_REFERENCE: &str = "latest"; @@ -539,7 +672,7 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String { .and_then(|f| f.to_str()) .unwrap_or(""); format!( - "no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible." + "no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: /resume {LATEST_SESSION_REFERENCE} searches all workspaces." ) } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 8a1ca77c5c..620ae67edf 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -5681,9 +5681,16 @@ fn latest_managed_session() -> Result Result<(SessionHandle, Session), Box> { - let loaded = current_session_store()? - .load_session(reference) - .map_err(|e| Box::new(e) as Box)?; + let store = current_session_store()?; + // For alias references ("latest", "last", "recent"), allow cross-workspace + // resume so /resume latest finds the most recent session globally. + // For explicit references, workspace validation is enforced. + let result = if runtime::session_control::is_session_reference_alias(reference) { + store.load_session_loose(reference) + } else { + store.load_session(reference) + }; + let loaded = result.map_err(|e| Box::new(e) as Box)?; Ok(( SessionHandle { id: loaded.handle.id, From 35ca0a4088c12e16be4799ec690b67fbb31511e1 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 19:59:59 -0500 Subject: [PATCH 08/18] fix: scan project-local .claw/sessions/ as well as global root The previous implementation only scanned ~/.claw/sessions/ for the global fallback, but sessions are actually stored in the project-local /.claw/sessions// by SessionStore::from_cwd(). Now scans both the global root and the project-local parent directory (checking all fingerprint subdirs) so /resume latest finds sessions regardless of where they're stored. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/session_control.rs | 55 ++++++++++++++-------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index bfb382d9e1..6a24e75d1c 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -164,7 +164,7 @@ impl SessionStore { } // Fallback: scan all workspace namespaces under ~/.claw/sessions/ // so that /resume latest can find sessions from other workspaces - if let Some(latest) = Self::scan_global_sessions()?.into_iter().next() { + if let Some(latest) = self.scan_global_sessions()?.into_iter().next() { return Ok(latest); } Err(SessionControlError::Format(format_no_managed_sessions( @@ -251,28 +251,43 @@ impl SessionStore { .map(Path::to_path_buf) } - /// Scan all workspace namespaces under the global sessions root - /// (`~/.claw/sessions/`) to find sessions from any workspace. - /// Used as a fallback when the current workspace has no sessions. - fn scan_global_sessions() -> Result, SessionControlError> { - let global_root = global_sessions_root(); - let entries = match fs::read_dir(&global_root) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; + /// Scan all known session storage locations for sessions from any workspace. + /// Checks both the global root (~/.claw/sessions/) and the project-local + /// .claw/sessions/ parent directory. Used as a fallback when the current + /// workspace has no sessions. + #[allow(clippy::unnecessary_wraps)] + fn scan_global_sessions(&self) -> Result, SessionControlError> { let mut sessions = Vec::new(); - for entry in entries { - let Ok(entry) = entry else { - continue; - }; - let path = entry.path(); - if !path.is_dir() { - continue; + + // Scan global root: ~/.claw/sessions// + let global_root = global_sessions_root(); + if let Ok(entries) = fs::read_dir(&global_root) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } } - // Silently ignore errors reading individual workspace dirs - let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); } + + // Scan project-local parent: /.claw/sessions// + // Sessions are stored here by from_cwd(), so we must check all + // fingerprint subdirs, not just the current workspace's. + if let Some(local_parent) = self.legacy_sessions_root() { + if let Ok(entries) = fs::read_dir(&local_parent) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path != self.sessions_root { + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } else if path == self.sessions_root { + // Already searched in list_sessions(), but include here + // in case this is called standalone + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } + } + } + } + sort_managed_sessions(&mut sessions); Ok(sessions) } From 7b9c6221f2941adfcd2346c2dcdb0fc8d27db4c5 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 20:35:08 -0500 Subject: [PATCH 09/18] fix: /resume latest skips current empty session Previously /resume latest returned the most recently created session, which was always the empty one just created on startup. Now it skips sessions with 0 messages and excludes the current session ID, so it finds the previous session with actual conversation history. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/session_control.rs | 79 ++++++++++++++++------ rust/crates/rusty-claude-cli/src/main.rs | 22 +++--- 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 6a24e75d1c..1d5edcd40f 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -93,8 +93,19 @@ impl SessionStore { } pub fn resolve_reference(&self, reference: &str) -> Result { + self.resolve_reference_excluding(reference, None) + } + + /// Resolve a session reference, optionally excluding a session by ID. + /// When the reference is an alias, the excluded session is skipped + /// so /resume latest returns the previous session, not the current one. + pub fn resolve_reference_excluding( + &self, + reference: &str, + exclude_id: Option<&str>, + ) -> Result { if is_session_reference_alias(reference) { - let latest = self.latest_session()?; + let latest = self.latest_session_excluding(exclude_id)?; return Ok(SessionHandle { id: latest.id, path: latest.path, @@ -158,13 +169,32 @@ impl SessionStore { } pub fn latest_session(&self) -> Result { + self.latest_session_excluding(None) + } + + /// Find the most recent session, optionally excluding a session by ID. + /// Used by /resume latest to skip the current empty session. + pub fn latest_session_excluding( + &self, + exclude_id: Option<&str>, + ) -> Result { + let exclude = exclude_id.unwrap_or(""); // First: look in the current workspace's session namespace - if let Some(latest) = self.list_sessions()?.into_iter().next() { + if let Some(latest) = self + .list_sessions()? + .into_iter() + .find(|s| s.id != exclude && s.message_count > 0) + { return Ok(latest); } // Fallback: scan all workspace namespaces under ~/.claw/sessions/ - // so that /resume latest can find sessions from other workspaces - if let Some(latest) = self.scan_global_sessions()?.into_iter().next() { + // and project-local .claw/sessions/ so /resume latest finds sessions + // from other workspaces. + if let Some(latest) = self + .scan_global_sessions()? + .into_iter() + .find(|s| s.id != exclude && s.message_count > 0) + { return Ok(latest); } Err(SessionControlError::Format(format_no_managed_sessions( @@ -196,28 +226,39 @@ impl SessionStore { &self, reference: &str, ) -> Result { - match self.load_session(reference) { - Ok(loaded) => Ok(loaded), - Err(SessionControlError::WorkspaceMismatch { expected, actual }) - if is_session_reference_alias(reference) => + self.load_session_excluding(reference, None) + } + + /// Like `load_session_loose` but also excludes a session by ID. + /// Used by /resume latest to skip the current empty session and find + /// the previous session with actual conversation history. + pub fn load_session_excluding( + &self, + reference: &str, + exclude_id: Option<&str>, + ) -> Result { + let handle = self.resolve_reference_excluding(reference, exclude_id)?; + let session = Session::load_from_path(&handle.path)?; + // For alias references, allow cross-workspace resume + if is_session_reference_alias(reference) { + if let Err(SessionControlError::WorkspaceMismatch { expected: _, actual }) = + self.validate_loaded_session(&handle.path, &session) { - let handle = self.resolve_reference(reference)?; - let session = Session::load_from_path(&handle.path)?; eprintln!( " Note: resuming session from a different workspace (origin: {})", actual.display() ); - let _ = expected; // suppress unused warning - Ok(LoadedManagedSession { - handle: SessionHandle { - id: session.session_id.clone(), - path: handle.path, - }, - session, - }) } - Err(other) => Err(other), + } else { + self.validate_loaded_session(&handle.path, &session)?; } + Ok(LoadedManagedSession { + handle: SessionHandle { + id: session.session_id.clone(), + path: handle.path, + }, + session, + }) } pub fn fork_session( diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 620ae67edf..11df67ea0c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -5178,7 +5178,8 @@ impl LiveCli { return Ok(false); }; - let (handle, session) = load_session_reference(&session_ref)?; + let (handle, session) = + load_session_reference_excluding(&session_ref, Some(&self.session.id))?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); let runtime = build_runtime( @@ -5680,17 +5681,18 @@ fn latest_managed_session() -> Result Result<(SessionHandle, Session), Box> { + load_session_reference_excluding(reference, None) +} + +fn load_session_reference_excluding( + reference: &str, + exclude_id: Option<&str>, ) -> Result<(SessionHandle, Session), Box> { let store = current_session_store()?; - // For alias references ("latest", "last", "recent"), allow cross-workspace - // resume so /resume latest finds the most recent session globally. - // For explicit references, workspace validation is enforced. - let result = if runtime::session_control::is_session_reference_alias(reference) { - store.load_session_loose(reference) - } else { - store.load_session(reference) - }; - let loaded = result.map_err(|e| Box::new(e) as Box)?; + let loaded = store + .load_session_excluding(reference, exclude_id) + .map_err(|e| Box::new(e) as Box)?; Ok(( SessionHandle { id: loaded.handle.id, From 2c55a7b9745111821d95a5f98315d1ffdcbe7311 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Mon, 27 Apr 2026 10:32:46 -0500 Subject: [PATCH 10/18] fix: write model at top-level in settings.json, not nested under provider The setup wizard wrote `model` inside the `provider` object, but `parse_optional_model` reads it from the top level. This caused the model setting to be silently ignored after `claw setup`. Also clean up the top-level `model` key when clearing provider settings. --- rust/crates/runtime/src/config.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1313a82ad1..868a15a60d 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -630,12 +630,12 @@ pub fn save_user_provider_settings( } else { provider.remove("baseUrl"); } + root.insert("provider".to_string(), serde_json::Value::Object(provider)); if let Some(model) = model { - provider.insert("model".to_string(), serde_json::Value::String(model.to_string())); + root.insert("model".to_string(), serde_json::Value::String(model.to_string())); } else { - provider.remove("model"); + root.remove("model"); } - root.insert("provider".to_string(), serde_json::Value::Object(provider)); write_settings_root(&settings_path, &root)?; @@ -662,6 +662,7 @@ pub fn clear_user_provider_settings() -> Result<(), ConfigError> { if root.remove("provider").is_none() { return Ok(()); } + root.remove("model"); write_settings_root(&settings_path, &root)?; From 7ba448b7e327542183e277b72a7d47f5053fc5cf Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 28 Apr 2026 22:01:49 -0500 Subject: [PATCH 11/18] feat: add fast model prompt to setup wizard claw setup now asks for a subagentModel (smaller/cheaper model for Agent subtasks) after the main model prompt. The value is written to ~/.claw/settings.json as subagentModel. Co-Authored-By: Claude Opus 4.7 --- .../rusty-claude-cli/src/setup_wizard.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs index e1513f712b..69fabfb36b 100644 --- a/rust/crates/rusty-claude-cli/src/setup_wizard.rs +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -2,6 +2,8 @@ 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"), @@ -47,6 +49,7 @@ pub fn run_setup_wizard() -> Result<(), Box> { 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, @@ -55,6 +58,10 @@ pub fn run_setup_wizard() -> Result<(), Box> { 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)); @@ -216,6 +223,60 @@ fn prompt_model( } } +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}")?; From 6ec425587128ce9d52e7643f4da275193a1dd080 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:32:40 -0500 Subject: [PATCH 12/18] 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 | 1 + 2 files changed, 3 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index a7dc0b4c51..70aacc9598 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 0a238e6d66..2f8461243c 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1481,6 +1481,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 }, From 305fbc7874304ccb8b84b21ae68c5d06f7477c42 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:40:25 -0500 Subject: [PATCH 13/18] 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 832f57cd8716b08d1c9b6f9384c9637c3cfa8e6e Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:42:46 -0500 Subject: [PATCH 14/18] 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 70aacc9598..353fe1fa85 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 99ca13eed58b81ecb5a18f9f6b20c48d2d1f38f5 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:46:32 -0500 Subject: [PATCH 15/18] 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 353fe1fa85..607ba79781 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 1cd6d9e814bdbbf77fe05798c64effbe114f27bd Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:04:43 -0500 Subject: [PATCH 16/18] 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 +++++++++++ rust/crates/commands/src/lib.rs | 3 --- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 607ba79781..f8daf57869 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)] diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 2f8461243c..c07143db00 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2710,7 +2710,6 @@ fn render_mcp_report_for( } } -<<<<<<< HEAD fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String { format!( "MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show |help]" @@ -2732,8 +2731,6 @@ fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value { }) } -======= ->>>>>>> 9b1593c (fix: /resume latest finds sessions across all workspaces) #[allow(clippy::unnecessary_wraps)] fn render_mcp_report_json_for( loader: &ConfigLoader, From d563f65fbfa71edf9d5ea732f4c91018a376c8f7 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:07:47 -0500 Subject: [PATCH 17/18] 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 f8daf57869..098d542d29 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 9f58553e6ef4f46652b6b6d4204642a3b77761af Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 22:36:37 -0500 Subject: [PATCH 18/18] 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];