diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 088a386b4a..0083ac236c 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -720,6 +720,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "setup", + aliases: &[], + summary: "Run the interactive provider setup wizard", + argument_hint: None, + resume_supported: false, + }, SlashCommandSpec { name: "notifications", aliases: &[], @@ -1102,6 +1109,7 @@ pub enum SlashCommand { args: Option, }, Doctor, + Setup, Login, Logout, Vim, @@ -1223,6 +1231,7 @@ impl SlashCommand { Self::Compact { .. } => "/compact", Self::Cost => "/cost", Self::Doctor => "/doctor", + Self::Setup => "/setup", Self::Config { .. } => "/config", Self::Memory { .. } => "/memory", Self::History { .. } => "/history", @@ -1392,6 +1401,10 @@ pub fn validate_slash_command_input( validate_no_args(command, &args)?; SlashCommand::Doctor } + "setup" => { + validate_no_args(command, &args)?; + SlashCommand::Setup + } "login" | "logout" => { return Err(command_error( "This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.", @@ -1911,7 +1924,7 @@ fn slash_command_category(name: &str) -> &'static str { | "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt" | "api-key" | "terminal-setup" | "notifications" | "telemetry" | "providers" | "env" | "project" | "reasoning" | "budget" | "rate-limit" | "workspace" | "reset" | "ide" - | "desktop" | "upgrade" => "Config", + | "desktop" | "upgrade" | "setup" => "Config", "debug-tool-call" | "doctor" | "sandbox" | "diagnostics" | "tool-details" | "changelog" | "metrics" => "Debug", _ => "Tools", @@ -5114,6 +5127,7 @@ pub fn handle_slash_command( | SlashCommand::AddDir { .. } | SlashCommand::History { .. } | SlashCommand::Team { .. } + | SlashCommand::Setup | SlashCommand::Unknown(_) => None, } } @@ -5730,7 +5744,8 @@ mod tests { assert!(help.contains("aliases: /skill")); assert!(!help.contains("/login")); assert!(!help.contains("/logout")); - assert_eq!(slash_command_specs().len(), 139); + assert!(help.contains("/setup")); + assert_eq!(slash_command_specs().len(), 140); assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 806c3ed528..6f966227bc 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -139,9 +139,45 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + provider: RuntimeProviderConfig, rules_import: RulesImportConfig, } +/// 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 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() + } +} + /// Controls which external AI coding framework rules are imported into the system prompt. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum RulesImportConfig { @@ -729,6 +765,7 @@ fn build_runtime_config( 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)?, rules_import: parse_optional_rules_import(&merged_value)?, }; @@ -834,6 +871,11 @@ impl RuntimeConfig { &self.feature_config.provider_fallbacks } + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.feature_config.provider + } + #[must_use] pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots @@ -919,6 +961,11 @@ impl RuntimeFeatureConfig { &self.provider_fallbacks } + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.provider + } + #[must_use] pub fn trusted_roots(&self) -> &[String] { &self.trusted_roots @@ -1794,6 +1841,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_optional_rules_import(root: &JsonValue) -> Result { let Some(object) = root.as_object() else { return Ok(RulesImportConfig::default()); diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index bea04572a0..2c835d3f28 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -154,6 +154,12 @@ struct DeprecatedField { replacement: &'static str, } +/// Canonical list of valid top-level settings keys for `settings.json`. +/// +/// This array is the single source of truth used by config validation; every +/// supported top-level key (including `subagentModel`, `provider`, +/// `trustedRoots`, `rulesImport`, etc.) must appear here with its expected +/// type. Add new top-level settings keys to this list. const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ FieldSpec { name: "$schema", @@ -215,6 +221,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "provider", expected: FieldType::Object, }, + FieldSpec { + name: "subagentModel", + expected: FieldType::String, + }, FieldSpec { name: "rulesImport", expected: FieldType::RulesImport, diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index e11b91d8c6..d578260fb7 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -65,13 +65,14 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ + clear_user_provider_settings, default_config_home, save_user_provider_settings, suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, - RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig, + RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a4f14a2e88..c388f712a6 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16,6 +16,7 @@ mod init; mod input; mod render; +mod setup_wizard; use std::collections::BTreeSet; use std::env; @@ -1086,6 +1087,7 @@ fn run() -> Result<(), Box> { CliAction::Acp { output_format } => print_acp_status(output_format)?, CliAction::State { output_format } => run_worker_state(output_format)?, CliAction::Init { output_format } => run_init(output_format)?, + CliAction::Setup { output_format: _ } => run_setup()?, // #146: dispatch pure-local introspection. Text mode uses existing // render_config_report/render_diff_report; JSON mode uses the // corresponding _json helpers already exposed for resume sessions. @@ -1225,6 +1227,9 @@ enum CliAction { Init { output_format: CliOutputFormat, }, + Setup { + output_format: CliOutputFormat, + }, // #146: `claw config` and `claw diff` are pure-local read-only // introspection commands; wire them as standalone CLI subcommands. Config { @@ -1288,6 +1293,7 @@ enum LocalHelpTopic { Model, Settings, Diff, + Setup, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1697,6 +1703,7 @@ fn parse_args(args: &[String]) -> Result { "doctor" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), + "setup" => Some(LocalHelpTopic::Setup), "state" => Some(LocalHelpTopic::State), "resume" => Some(LocalHelpTopic::Resume), "session" => Some(LocalHelpTopic::Session), @@ -2050,6 +2057,15 @@ fn parse_args(args: &[String]) -> Result { } Ok(CliAction::Init { output_format }) } + "setup" => { + if rest.len() > 1 { + let extra = rest[1..].join(" "); + return Err(format!( + "unexpected extra arguments after `claw setup`: {extra}\nUsage: claw setup" + )); + } + Ok(CliAction::Setup { output_format }) + } "export" => parse_export_args(&rest[1..], output_format), "prompt" => { let mut read_stdin = false; @@ -2177,6 +2193,7 @@ fn parse_local_help_action( "doctor" => LocalHelpTopic::Doctor, "acp" => LocalHelpTopic::Acp, "init" => LocalHelpTopic::Init, + "setup" => LocalHelpTopic::Setup, "state" => LocalHelpTopic::State, "export" => LocalHelpTopic::Export, "version" => LocalHelpTopic::Version, @@ -2222,7 +2239,7 @@ fn parse_single_word_command_alias( let verb = &rest[0]; let is_diagnostic = matches!( verb.as_str(), - "help" | "version" | "status" | "sandbox" | "doctor" | "state" + "help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state" ); if is_diagnostic && rest.len() > 1 { @@ -2242,6 +2259,7 @@ fn parse_single_word_command_alias( "doctor" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), + "setup" => Some(LocalHelpTopic::Setup), "state" => Some(LocalHelpTopic::State), "export" => Some(LocalHelpTopic::Export), "version" => Some(LocalHelpTopic::Version), @@ -2296,6 +2314,7 @@ fn parse_single_word_command_alias( "doctor" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), + "setup" => Some(LocalHelpTopic::Setup), "state" => Some(LocalHelpTopic::State), "export" => Some(LocalHelpTopic::Export), "version" => Some(LocalHelpTopic::Version), @@ -2348,6 +2367,7 @@ fn parse_single_word_command_alias( .map(PermissionModeProvenance::from_flag) .unwrap_or_else(permission_mode_provenance_for_current_dir), })), + "setup" => Some(Ok(CliAction::Setup { output_format })), "state" => Some(Ok(CliAction::State { output_format })), // #146: let `config` and `diff` fall through to parse_subcommand // where they are wired as pure-local introspection, instead of @@ -2640,6 +2660,7 @@ fn suggest_similar_subcommand(input: &str) -> Option> { "status", "sandbox", "doctor", + "setup", "state", "dump-manifests", "bootstrap-plan", @@ -3573,6 +3594,11 @@ fn run_doctor( Ok(()) } +/// Run the interactive setup wizard to configure provider, API key, and model. +fn run_setup() -> Result<(), Box> { + setup_wizard::run_setup_wizard() +} + /// Starts a minimal Model Context Protocol server that exposes claw's /// built-in tools over stdio. /// @@ -6380,7 +6406,8 @@ fn run_resume_command( | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } - | SlashCommand::Team { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::Team { .. } + | SlashCommand::Setup => Err("unsupported resumed slash command".into()), } } @@ -7594,6 +7621,12 @@ impl LiveCli { ); false } + SlashCommand::Setup => { + if let Err(e) = setup_wizard::run_setup_wizard() { + eprintln!("Setup wizard failed: {e}"); + } + false + } SlashCommand::History { count } => { self.print_prompt_history(count.as_deref()); false @@ -9570,6 +9603,13 @@ fn render_help_topic(topic: LocalHelpTopic) -> String { Formats text (default), json Related /diff · ROADMAP #148" .to_string(), + LocalHelpTopic::Setup => "Setup + Usage claw setup + Aliases /setup (inside the REPL) + Purpose run the interactive provider setup wizard to configure API key, model, and base URL + Output writes provider settings to ~/.claw/settings.json (0600 permissions) + Related /model · /config · claw doctor" + .to_string(), } } @@ -9597,6 +9637,7 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { LocalHelpTopic::Model => "models", LocalHelpTopic::Settings => "settings", LocalHelpTopic::Diff => "diff", + LocalHelpTopic::Setup => "setup", } } diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs index 69fabfb36b..c2f7b6ff39 100644 --- a/rust/crates/rusty-claude-cli/src/setup_wizard.rs +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -23,7 +23,10 @@ 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"), + ( + "dashscope", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + ), ]; const API_KEY_ENV_VARS: &[(&str, &str)] = &[ @@ -51,12 +54,7 @@ pub fn run_setup_wizard() -> Result<(), Box> { let model = prompt_model(&kind, ¤t)?; let fast_model = prompt_fast_model(¤t, model.as_deref())?; - save_user_provider_settings( - &kind, - &api_key, - base_url.as_deref(), - model.as_deref(), - )?; + save_user_provider_settings(&kind, &api_key, base_url.as_deref(), model.as_deref())?; if let Some(fast) = &fast_model { save_settings_field("subagentModel", fast)?; @@ -64,7 +62,10 @@ pub fn run_setup_wizard() -> Result<(), Box> { println!(); println!(" \x1b[32mProvider saved to ~/.claw/settings.json\x1b[0m"); - println!(" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.", model.as_deref().unwrap_or(&kind)); + println!( + " Run \x1b[1m/model {}\x1b[0m or restart claw to activate.", + model.as_deref().unwrap_or(&kind) + ); println!(); Ok(()) @@ -82,7 +83,11 @@ fn prompt_provider(current: &RuntimeProviderConfig) -> Result "DASHSCOPE_BASE_URL", _ => "BASE_URL", }; - let env_set = std::env::var(env_var) - .ok() - .is_some_and(|v| !v.is_empty()); + let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty()); if env_set { println!(" {env_var} is set in environment (will take priority over stored URL)"); } @@ -203,7 +206,9 @@ fn prompt_model( .find(|(k, _)| *k == kind) .map_or(empty, |(_, models)| *models); - let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or("")); + let current_model = current + .model() + .unwrap_or(aliases.first().copied().unwrap_or("")); println!(" \x1b[1mModel\x1b[0m"); if !aliases.is_empty() { @@ -235,12 +240,16 @@ fn prompt_fast_model( println!(" Press Enter to skip (agents will use your main model)."); let current_fast = load_current_settings_field("subagentModel"); - let default_hint = current_fast - .as_deref() - .or(main_model) - .unwrap_or(""); + let default_hint = current_fast.as_deref().or(main_model).unwrap_or(""); - let input = read_line(&format!(" Fast model [{}]: ", if default_hint.is_empty() { "same as main" } else { default_hint }))?; + let input = read_line(&format!( + " Fast model [{}]: ", + if default_hint.is_empty() { + "same as main" + } else { + default_hint + } + ))?; if input.trim().is_empty() { Ok(current_fast) } else { @@ -269,7 +278,10 @@ fn save_settings_field(field: &str, value: &str) -> Result<(), Box