Skip to content

Commit b1f8451

Browse files
feat: add interactive provider wizard with /setup, claw setup, and Ctrl+P
Adds an interactive setup wizard that lets users configure their provider, API key, base URL, and model without setting environment variables. Configuration is persisted to ~/.claw/settings.json (with 0600 permissions). New features: - `claw setup` CLI subcommand runs the wizard from the terminal - `/setup` slash command runs the wizard inside the REPL (hot-swaps model) - Ctrl+P hotkey in the REPL triggers /setup for in-session provider swap - Stored provider config used as fallback when env vars are absent - Three-tier auth resolution: env var > .env file > stored config - RuntimeProviderConfig struct and validation in settings schema Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3f23a86 commit b1f8451

10 files changed

Lines changed: 539 additions & 20 deletions

File tree

rust/crates/api/src/providers/anthropic.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -738,11 +738,7 @@ fn now_unix_timestamp() -> u64 {
738738
}
739739

740740
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
741-
match std::env::var(key) {
742-
Ok(value) if !value.is_empty() => Ok(Some(value)),
743-
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
744-
Err(error) => Err(ApiError::from(error)),
745-
}
741+
super::read_env_or_config(key)
746742
}
747743

748744
#[cfg(test)]
@@ -763,7 +759,10 @@ fn read_auth_token() -> Option<String> {
763759

764760
#[must_use]
765761
pub fn read_base_url() -> String {
766-
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
762+
super::read_env_or_config("ANTHROPIC_BASE_URL")
763+
.ok()
764+
.flatten()
765+
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
767766
}
768767

769768
fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {

rust/crates/api/src/providers/mod.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,65 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
247247
if std::env::var_os("OPENAI_BASE_URL").is_some() {
248248
return ProviderKind::OpenAi;
249249
}
250+
// Fallback: check stored provider config from setup wizard.
251+
if let Some(kind) = stored_provider_kind() {
252+
return kind;
253+
}
250254
ProviderKind::Anthropic
251255
}
252256

257+
/// Look up a stored provider config value by env var name.
258+
/// Returns the stored API key or base URL when the env var matches the
259+
/// configured provider kind, enabling the setup wizard to persist credentials
260+
/// that work without shell env vars.
261+
pub fn provider_config_value(key: &str) -> Option<String> {
262+
let cwd = std::env::current_dir().ok()?;
263+
let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?;
264+
let provider = config.provider();
265+
let kind = provider.kind()?;
266+
match (key, kind) {
267+
("ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN", "anthropic") => provider.api_key().map(ToOwned::to_owned),
268+
("ANTHROPIC_BASE_URL", "anthropic") => provider.base_url().map(ToOwned::to_owned),
269+
("XAI_API_KEY", "xai") => provider.api_key().map(ToOwned::to_owned),
270+
("XAI_BASE_URL", "xai") => provider.base_url().map(ToOwned::to_owned),
271+
("OPENAI_API_KEY", "openai") => provider.api_key().map(ToOwned::to_owned),
272+
("OPENAI_BASE_URL", "openai") => provider.base_url().map(ToOwned::to_owned),
273+
("DASHSCOPE_API_KEY", "dashscope") => provider.api_key().map(ToOwned::to_owned),
274+
("DASHSCOPE_BASE_URL", "dashscope") => provider.base_url().map(ToOwned::to_owned),
275+
_ => None,
276+
}
277+
}
278+
279+
/// Read an env var with a 3-tier fallback: process env -> .env file -> stored config.
280+
/// Environment variables always take priority over stored settings.
281+
pub fn read_env_or_config(key: &str) -> Result<Option<String>, ApiError> {
282+
match std::env::var(key) {
283+
Ok(value) if !value.is_empty() => return Ok(Some(value)),
284+
Ok(_) | Err(std::env::VarError::NotPresent) => {}
285+
Err(error) => return Err(ApiError::from(error)),
286+
}
287+
if let Some(value) = dotenv_value(key) {
288+
return Ok(Some(value));
289+
}
290+
if let Some(value) = provider_config_value(key) {
291+
return Ok(Some(value));
292+
}
293+
Ok(None)
294+
}
295+
296+
/// Return the stored ProviderKind from config, if set.
297+
fn stored_provider_kind() -> Option<ProviderKind> {
298+
let cwd = std::env::current_dir().ok()?;
299+
let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?;
300+
let kind = config.provider().kind()?;
301+
match kind {
302+
"anthropic" => Some(ProviderKind::Anthropic),
303+
"xai" => Some(ProviderKind::Xai),
304+
"openai" => Some(ProviderKind::OpenAi),
305+
_ => None,
306+
}
307+
}
308+
253309
#[must_use]
254310
pub fn max_tokens_for_model(model: &str) -> u32 {
255311
model_token_limit(model).map_or_else(

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,11 +1303,7 @@ fn parse_sse_frame(
13031303
}
13041304

13051305
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
1306-
match std::env::var(key) {
1307-
Ok(value) if !value.is_empty() => Ok(Some(value)),
1308-
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
1309-
Err(error) => Err(ApiError::from(error)),
1310-
}
1306+
super::read_env_or_config(key)
13111307
}
13121308

13131309
#[must_use]
@@ -1320,7 +1316,10 @@ pub fn has_api_key(key: &str) -> bool {
13201316

13211317
#[must_use]
13221318
pub fn read_base_url(config: OpenAiCompatConfig) -> String {
1323-
std::env::var(config.base_url_env).unwrap_or_else(|_| config.default_base_url.to_string())
1319+
super::read_env_or_config(config.base_url_env)
1320+
.ok()
1321+
.flatten()
1322+
.unwrap_or_else(|| config.default_base_url.to_string())
13241323
}
13251324

13261325
fn chat_completions_endpoint(base_url: &str) -> String {

rust/crates/commands/src/lib.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
313313
argument_hint: None,
314314
resume_supported: true,
315315
},
316+
SlashCommandSpec {
317+
name: "setup",
318+
aliases: &[],
319+
summary: "Configure provider, API key, and model interactively",
320+
argument_hint: None,
321+
resume_supported: true,
322+
},
316323
SlashCommandSpec {
317324
name: "stats",
318325
aliases: &[],
@@ -1140,6 +1147,7 @@ pub enum SlashCommand {
11401147
Usage {
11411148
scope: Option<String>,
11421149
},
1150+
Setup,
11431151
Rename {
11441152
name: Option<String>,
11451153
},
@@ -1265,6 +1273,7 @@ impl SlashCommand {
12651273
Self::Theme { .. } => "/theme",
12661274
Self::Voice { .. } => "/voice",
12671275
Self::Usage { .. } => "/usage",
1276+
Self::Setup => "/setup",
12681277
Self::Rename { .. } => "/rename",
12691278
Self::Copy { .. } => "/copy",
12701279
Self::Hooks { .. } => "/hooks",
@@ -1476,6 +1485,7 @@ pub fn validate_slash_command_input(
14761485
"theme" => SlashCommand::Theme { name: remainder },
14771486
"voice" => SlashCommand::Voice { mode: remainder },
14781487
"usage" => SlashCommand::Usage { scope: remainder },
1488+
"setup" => SlashCommand::Setup,
14791489
"rename" => SlashCommand::Rename { name: remainder },
14801490
"copy" => SlashCommand::Copy { target: remainder },
14811491
"hooks" => SlashCommand::Hooks { args: remainder },
@@ -4169,6 +4179,7 @@ pub fn handle_slash_command(
41694179
| SlashCommand::OutputStyle { .. }
41704180
| SlashCommand::AddDir { .. }
41714181
| SlashCommand::History { .. }
4182+
| SlashCommand::Setup => None,
41724183
| SlashCommand::Unknown(_) => None,
41734184
}
41744185
}
@@ -4706,7 +4717,7 @@ mod tests {
47064717
assert!(help.contains("aliases: /skill"));
47074718
assert!(!help.contains("/login"));
47084719
assert!(!help.contains("/logout"));
4709-
assert_eq!(slash_command_specs().len(), 139);
4720+
assert_eq!(slash_command_specs().len(), 140);
47104721
assert!(resume_supported_slash_commands().len() >= 39);
47114722
}
47124723

rust/crates/runtime/src/config.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ pub struct RuntimeFeatureConfig {
6565
sandbox: SandboxConfig,
6666
provider_fallbacks: ProviderFallbackConfig,
6767
trusted_roots: Vec<String>,
68+
provider: RuntimeProviderConfig,
69+
}
70+
71+
/// Stored provider configuration from the setup wizard.
72+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
73+
pub struct RuntimeProviderConfig {
74+
kind: Option<String>,
75+
api_key: Option<String>,
76+
base_url: Option<String>,
77+
model: Option<String>,
78+
}
79+
80+
impl RuntimeProviderConfig {
81+
#[must_use]
82+
pub fn kind(&self) -> Option<&str> {
83+
self.kind.as_deref()
84+
}
85+
86+
#[must_use]
87+
pub fn api_key(&self) -> Option<&str> {
88+
self.api_key.as_deref()
89+
}
90+
91+
#[must_use]
92+
pub fn base_url(&self) -> Option<&str> {
93+
self.base_url.as_deref()
94+
}
95+
96+
#[must_use]
97+
pub fn model(&self) -> Option<&str> {
98+
self.model.as_deref()
99+
}
68100
}
69101

70102
/// Ordered chain of fallback model identifiers used when the primary
@@ -315,6 +347,7 @@ impl ConfigLoader {
315347
sandbox: parse_optional_sandbox_config(&merged_value)?,
316348
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
317349
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
350+
provider: parse_optional_provider_config(&merged_value)?,
318351
};
319352

320353
Ok(RuntimeConfig {
@@ -414,6 +447,11 @@ impl RuntimeConfig {
414447
pub fn trusted_roots(&self) -> &[String] {
415448
&self.feature_config.trusted_roots
416449
}
450+
451+
#[must_use]
452+
pub fn provider(&self) -> &RuntimeProviderConfig {
453+
&self.feature_config.provider
454+
}
417455
}
418456

419457
impl RuntimeFeatureConfig {
@@ -483,6 +521,11 @@ impl RuntimeFeatureConfig {
483521
pub fn trusted_roots(&self) -> &[String] {
484522
&self.trusted_roots
485523
}
524+
525+
#[must_use]
526+
pub fn provider(&self) -> &RuntimeProviderConfig {
527+
&self.provider
528+
}
486529
}
487530

488531
impl ProviderFallbackConfig {
@@ -564,6 +607,91 @@ pub fn default_config_home() -> PathBuf {
564607
.unwrap_or_else(|| PathBuf::from(".claw"))
565608
}
566609

610+
/// Save provider settings to the user-level `~/.claw/settings.json`.
611+
/// Creates the file and directory if they don't exist. Sets file permissions
612+
/// to `0o600` (owner read/write only) to protect stored API keys.
613+
pub fn save_user_provider_settings(
614+
kind: &str,
615+
api_key: &str,
616+
base_url: Option<&str>,
617+
model: Option<&str>,
618+
) -> Result<(), ConfigError> {
619+
let config_home = default_config_home();
620+
fs::create_dir_all(&config_home).map_err(ConfigError::Io)?;
621+
let settings_path = config_home.join("settings.json");
622+
623+
let mut root = read_settings_root(&settings_path);
624+
625+
let mut provider = serde_json::Map::new();
626+
provider.insert("kind".to_string(), serde_json::Value::String(kind.to_string()));
627+
provider.insert("apiKey".to_string(), serde_json::Value::String(api_key.to_string()));
628+
if let Some(base_url) = base_url {
629+
provider.insert("baseUrl".to_string(), serde_json::Value::String(base_url.to_string()));
630+
} else {
631+
provider.remove("baseUrl");
632+
}
633+
if let Some(model) = model {
634+
provider.insert("model".to_string(), serde_json::Value::String(model.to_string()));
635+
} else {
636+
provider.remove("model");
637+
}
638+
root.insert("provider".to_string(), serde_json::Value::Object(provider));
639+
640+
write_settings_root(&settings_path, &root)?;
641+
642+
#[cfg(unix)]
643+
{
644+
use std::os::unix::fs::PermissionsExt;
645+
let perms = std::fs::Permissions::from_mode(0o600);
646+
fs::set_permissions(&settings_path, perms).map_err(ConfigError::Io)?;
647+
}
648+
649+
Ok(())
650+
}
651+
652+
/// Remove the `provider` section from the user-level `~/.claw/settings.json`.
653+
pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
654+
let config_home = default_config_home();
655+
let settings_path = config_home.join("settings.json");
656+
657+
if !settings_path.exists() {
658+
return Ok(());
659+
}
660+
661+
let mut root = read_settings_root(&settings_path);
662+
if root.remove("provider").is_none() {
663+
return Ok(());
664+
}
665+
666+
write_settings_root(&settings_path, &root)?;
667+
668+
Ok(())
669+
}
670+
671+
fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
672+
match fs::read_to_string(path) {
673+
Ok(contents) if !contents.trim().is_empty() => {
674+
serde_json::from_str::<serde_json::Value>(&contents)
675+
.ok()
676+
.and_then(|v| v.as_object().cloned())
677+
.unwrap_or_default()
678+
}
679+
_ => serde_json::Map::new(),
680+
}
681+
}
682+
683+
fn write_settings_root(
684+
path: &Path,
685+
root: &serde_json::Map<String, serde_json::Value>,
686+
) -> Result<(), ConfigError> {
687+
if let Some(parent) = path.parent() {
688+
fs::create_dir_all(parent).map_err(ConfigError::Io)?;
689+
}
690+
let rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone()))
691+
.map_err(|e| ConfigError::Parse(e.to_string()))?;
692+
fs::write(path, format!("{rendered}\n")).map_err(ConfigError::Io)
693+
}
694+
567695
impl RuntimeHookConfig {
568696
#[must_use]
569697
pub fn new(
@@ -950,6 +1078,25 @@ fn parse_optional_oauth_config(
9501078
}))
9511079
}
9521080

1081+
fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderConfig, ConfigError> {
1082+
let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else {
1083+
return Ok(RuntimeProviderConfig::default());
1084+
};
1085+
let Some(object) = provider_value.as_object() else {
1086+
return Ok(RuntimeProviderConfig::default());
1087+
};
1088+
let kind = optional_string(object, "kind", "provider")?.map(str::to_string);
1089+
let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string);
1090+
let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string);
1091+
let model = optional_string(object, "model", "provider")?.map(str::to_string);
1092+
Ok(RuntimeProviderConfig {
1093+
kind,
1094+
api_key,
1095+
base_url,
1096+
model,
1097+
})
1098+
}
1099+
9531100
fn parse_mcp_server_config(
9541101
server_name: &str,
9551102
value: &JsonValue,

0 commit comments

Comments
 (0)