From b64a30ef48ddd305415a7b3c89be99f76150fad1 Mon Sep 17 00:00:00 2001 From: zhaoyanchao Date: Tue, 23 Jun 2026 20:32:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90setup=20wizard?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/crates/runtime/src/config.rs | 166 ++++++++++++++ rust/crates/runtime/src/lib.rs | 13 +- rust/crates/rusty-claude-cli/src/main.rs | 1 + .../rusty-claude-cli/src/setup_wizard.rs | 204 ++++++++++++++++++ 4 files changed, 378 insertions(+), 6 deletions(-) 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 15661892..0c9c76c1 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -65,6 +65,7 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + provider: RuntimeProviderConfig, } /// Ordered chain of fallback model identifiers used when the primary @@ -92,6 +93,51 @@ pub struct RuntimePermissionRuleConfig { ask: Vec, } +/// Stored provider configuration from the setup wizard. +/// +/// Represents the `provider` section in `~/.claw/settings.json`, used as a +/// fallback when environment variables are absent (3-tier resolution: +/// env var > .env file > stored config). +#[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 new(kind: Option, api_key: Option, base_url: Option, model: Option) -> Self { + Self { + kind, + api_key, + base_url, + model, + } + } + + #[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() + } +} + /// Collection of configured MCP servers after scope-aware merging. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct McpConfigCollection { @@ -315,6 +361,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 +461,15 @@ impl RuntimeConfig { pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots } + + #[must_use] + pub fn provider(&self) -> Option<&RuntimeProviderConfig> { + if self.feature_config.provider.kind.is_some() { + Some(&self.feature_config.provider) + } else { + None + } + } } impl RuntimeFeatureConfig { @@ -914,6 +970,25 @@ fn parse_optional_trusted_roots(root: &JsonValue) -> Result, ConfigE ) } +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_filesystem_mode_label(value: &str) -> Result { match value { "off" => Ok(FilesystemIsolationMode::Off), @@ -2119,3 +2194,94 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } } + + +fn read_settings_root(path: &Path) -> BTreeMap { + let content = fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string()); + if content.trim().is_empty() { + return BTreeMap::new(); + } + + match JsonValue::parse(&content) { + Ok(JsonValue::Object(root)) => root, + _ => BTreeMap::new(), + } +} + +fn write_settings_root(path: &Path, root: &BTreeMap) -> Result<(), ConfigError> { + let json_value = JsonValue::Object(root.clone()); + let content = json_value.render(); + + fs::write(path, content).map_err(ConfigError::Io)?; + Ok(()) +} + +/// 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(config: &RuntimeProviderConfig) -> 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 = BTreeMap::new(); + + if let Some(kind) = &config.kind { + provider.insert("kind".to_string(), JsonValue::String(kind.clone())); + } + + if let Some(api_key) = &config.api_key { + provider.insert("apiKey".to_string(), JsonValue::String(api_key.clone())); + } + + if let Some(base_url) = &config.base_url { + provider.insert("baseUrl".to_string(), JsonValue::String(base_url.clone())); + } + + if let Some(model) = &config.model { + provider.insert("model".to_string(), JsonValue::String(model.clone())); + } + + if !provider.is_empty() { + root.insert("provider".to_string(), JsonValue::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); + root.remove("provider"); + + if root.is_empty() { + let _ = fs::remove_file(&settings_path); + } else { + write_settings_root(&settings_path, &root)?; + } + + Ok(()) +} + +pub fn suppress_config_warnings_for_json_mode() { + // This function prevents config warnings from interfering with JSON output + // Currently a no-op, but reserved for future implementation if needed +} diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index c7d87091..657529a1 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, - ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, - RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig, - CLAW_SETTINGS_SCHEMA_NAME, + clear_user_provider_settings, default_config_home, save_user_provider_settings, + suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, + McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, + McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, + McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, + RuntimeConfig, RuntimeFeatureConfig, 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/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index aadacc42..8cfa15e2 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -31,6 +31,7 @@ mod render; mod tool_executor; mod runtime_builder; mod repl_commands; +mod setup_wizard; pub(crate) use api_client::*; pub(crate) use args::*; 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..4fd29962 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -0,0 +1,204 @@ +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 provider = select_provider(¤t)?; + let api_key = input_api_key(&provider, ¤t)?; + let base_url = input_base_url(&provider, ¤t)?; + let model = input_model(&provider, ¤t)?; + + let config = RuntimeProviderConfig::new( + Some(provider.to_string()), + Some(api_key), + Some(base_url), + Some(model), + ); + + runtime::save_user_provider_settings(&config)?; + println!("\n✅ 设置已保存到 ~/.claw/settings.json\n"); + Ok(()) +} + +fn load_current_provider_config() -> Option { + // For now, return None. This should be implemented based on how + // config loading is actually done in the runtime. + None +} + +fn select_provider(_current: &Option) -> Result> { + println!("Available providers:"); + for (id, name, _) in PROVIDERS { + println!(" {}. {}", id, name); + } + println!(); + + loop { + print!("Select provider (1-5): "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let choice = input.trim(); + + if let Some((_, _, kind)) = PROVIDERS.iter().find(|(id, _, _)| *id == choice) { + return Ok(kind.to_string()); + } + + println!("Invalid choice. Please enter 1-5."); + } +} + +fn input_api_key(provider: &str, current: &Option) -> Result> { + let env_var = API_KEY_ENV_VARS + .iter() + .find(|(p, _)| *p == provider) + .map(|(_, env)| *env) + .unwrap_or("API_KEY"); + + let current_value = current.as_ref().and_then(|c| c.api_key()).unwrap_or(""); + +println!("\nAPI Key (from {} environment variable):", env_var); + if !current_value.is_empty() { + println!(" Current: {}...", ¤t_value[..std::cmp::min(8, current_value.len())]); + } + + loop { + print!("Enter API key (or press Enter to keep current): "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let key = input.trim(); + + if key.is_empty() { + if current_value.is_empty() { + println!("API key is required."); + continue; + } + return Ok(current_value.to_string()); + } + + if key.len() >= 16 { + return Ok(key.to_string()); + } + + println!("API key seems too short. Please check your input."); + } +} + +fn input_base_url(provider: &str, current: &Option) -> Result> { + let default_url = DEFAULT_BASE_URLS + .iter() + .find(|(p, _)| *p == provider) + .map(|(_, url)| *url) + .unwrap_or("https://api.openai.com/v1"); + + let current_value = current.as_ref().and_then(|c| c.base_url()).unwrap_or(""); + + println!("\nBase URL:"); + if current_value.is_empty() { + println!(" Default: {}", default_url); + } else { + println!(" Current: {}", current_value); + } + + print!("Enter base URL (or press Enter for default): "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let url = input.trim(); + + if url.is_empty() { + Ok(if current_value.is_empty() { + default_url.to_string() + } else { + current_value.to_string() + }) + } else { + Ok(url.to_string()) + } +} + +fn input_model(provider: &str, current: &Option) -> Result> { + let available_models = PROVIDER_MODELS + .iter() + .find(|(p, _)| *p == provider) + .map(|(_, models)| *models) + .unwrap_or(&["custom-model"]); + + let current_value = current.as_ref().and_then(|c| c.model()).unwrap_or(""); + + println!("\nAvailable models for {}:", provider); + for (i, model) in available_models.iter().enumerate() { + println!(" {}. {}", i + 1, model); + } + + if current_value.is_empty() { + println!(" Current: (none)"); + } else { + println!(" Current: {}", current_value); + } + + loop { + print!("Enter model name (or press Enter for first option): "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let model = input.trim(); + + if model.is_empty() { + let default = if current_value.is_empty() { + available_models[0] + } else { + current_value + }; + return Ok(default.to_string()); + } + + return Ok(model.to_string()); + } +} \ No newline at end of file