Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions rust/crates/api/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,11 +738,7 @@ fn now_unix_timestamp() -> u64 {
}

fn read_env_non_empty(key: &str) -> Result<Option<String>, 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)]
Expand All @@ -763,7 +759,10 @@ fn read_auth_token() -> Option<String> {

#[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<String> {
Expand Down
56 changes: 56 additions & 0 deletions rust/crates/api/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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")
| ("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,
}
}

/// 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<Option<String>, 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<ProviderKind> {
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 fn max_tokens_for_model(model: &str) -> u32 {
model_token_limit(model).map_or_else(
Expand Down
11 changes: 5 additions & 6 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1303,11 +1303,7 @@ fn parse_sse_frame(
}

fn read_env_non_empty(key: &str) -> Result<Option<String>, 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]
Expand All @@ -1320,7 +1316,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 {
Expand Down
15 changes: 14 additions & 1 deletion rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: &[],
Expand Down Expand Up @@ -1140,6 +1147,7 @@ pub enum SlashCommand {
Usage {
scope: Option<String>,
},
Setup,
Rename {
name: Option<String>,
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -2537,6 +2547,7 @@ pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
))
}

#[allow(clippy::unnecessary_wraps)]
fn render_mcp_report_for(
loader: &ConfigLoader,
cwd: &Path,
Expand Down Expand Up @@ -2600,6 +2611,7 @@ fn render_mcp_report_for(
}
}

#[allow(clippy::unnecessary_wraps)]
fn render_mcp_report_json_for(
loader: &ConfigLoader,
cwd: &Path,
Expand Down Expand Up @@ -4169,6 +4181,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
}
}
Expand Down Expand Up @@ -4706,7 +4719,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);
}

Expand Down
148 changes: 148 additions & 0 deletions rust/crates/runtime/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,38 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig,
provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>,
provider: RuntimeProviderConfig,
}

/// Stored provider configuration from the setup wizard.
#[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 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -564,6 +607,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<String, serde_json::Value> {
match fs::read_to_string(path) {
Ok(contents) if !contents.trim().is_empty() => {
serde_json::from_str::<serde_json::Value>(&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<String, serde_json::Value>,
) -> 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(
Expand Down Expand Up @@ -950,6 +1079,25 @@ fn parse_optional_oauth_config(
}))
}

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_mcp_server_config(
server_name: &str,
value: &JsonValue,
Expand Down
Loading