feat: 集成setup wizard功能
This commit is contained in:
parent
d5021287d7
commit
b64a30ef48
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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(¤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<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: {}...", ¤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<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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue