feat: 集成setup wizard功能

This commit is contained in:
zhaoyanchao 2026-06-23 20:32:20 +08:00
parent d5021287d7
commit b64a30ef48
4 changed files with 378 additions and 6 deletions

View File

@ -65,6 +65,7 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig,
provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>,
provider: RuntimeProviderConfig,
}
/// Ordered chain of fallback model identifiers used when the primary
@ -92,6 +93,51 @@ pub struct RuntimePermissionRuleConfig {
ask: Vec<String>,
}
/// 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<String>,
api_key: Option<String>,
base_url: Option<String>,
model: Option<String>,
}
impl RuntimeProviderConfig {
#[must_use]
pub fn new(kind: Option<String>, api_key: Option<String>, base_url: Option<String>, model: Option<String>) -> 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<Vec<String>, ConfigE
)
}
fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderConfig, ConfigError> {
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<FilesystemIsolationMode, ConfigError> {
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<String, JsonValue> {
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<String, JsonValue>) -> 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
}

View File

@ -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,

View File

@ -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::*;

View File

@ -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<dyn std::error::Error>> {
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(&current)?;
let api_key = input_api_key(&provider, &current)?;
let base_url = input_base_url(&provider, &current)?;
let model = input_model(&provider, &current)?;
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<RuntimeProviderConfig> {
// 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<RuntimeProviderConfig>) -> Result<String, Box<dyn std::error::Error>> {
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<RuntimeProviderConfig>) -> Result<String, Box<dyn std::error::Error>> {
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: {}...", &current_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<RuntimeProviderConfig>) -> Result<String, Box<dyn std::error::Error>> {
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<RuntimeProviderConfig>) -> Result<String, Box<dyn std::error::Error>> {
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());
}
}