From cef45efc169507cd72edefe1778c35b275076d9c Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 24 May 2026 21:24:20 -0500 Subject: [PATCH] feat: interactive provider wizard with fast model selection Adds interactive provider setup wizard. --- rust/crates/runtime/src/config.rs | 86 ++++++ .../rusty-claude-cli/src/setup_wizard.rs | 287 ++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 rust/crates/rusty-claude-cli/src/setup_wizard.rs diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index b9a9e36a..a979e24c 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -592,6 +592,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 00000000..69fabfb3 --- /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) +}