From 3845040b9d4b0b58ea08aca1ab05e82d237b4590 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 4 Jun 2026 21:52:48 -0500 Subject: [PATCH 1/2] feat: wizard entry points -- /setup command, claw setup subcommand, and RuntimeProviderConfig The setup wizard was merged in PR #3017 but was orphaned -- it was not declared as a module in main.rs, making it unreachable. Additionally, the setup_wizard.rs imports RuntimeProviderConfig which did not exist on upstream/main. This commit makes the wizard accessible and adds the necessary RuntimeProviderConfig type. Changes: - Add RuntimeProviderConfig struct to runtime/src/config.rs with kind(), api_key(), base_url(), model() accessors. - Add parse_optional_provider_config() to parse the provider object from merged settings JSON. - Add provider() method to RuntimeConfig and RuntimeFeatureConfig. - Export RuntimeProviderConfig, save_user_provider_settings, clear_user_provider_settings, and default_config_home from runtime crate public API (runtime/src/lib.rs). - Add mod setup_wizard to rusty-claude-cli/src/main.rs. - Add claw setup CLI subcommand. - Add /setup slash command. - Add Setup variant to SlashCommand enum. - Add Setup to LocalHelpTopic enum. - Add setup to diagnostic subcommand matching. - Add subagentModel to TOP_LEVEL_FIELDS in config_validate.rs. Co-Authored-By: Claude Opus 4.8 --- rust/crates/commands/src/lib.rs | 16 +- rust/crates/runtime/src/config.rs | 63 ++++++ rust/crates/runtime/src/config_validate.rs | 180 +++--------------- rust/crates/runtime/src/lib.rs | 18 +- rust/crates/rusty-claude-cli/src/main.rs | 45 ++++- .../rusty-claude-cli/src/setup_wizard.rs | 58 +++--- 6 files changed, 192 insertions(+), 188 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 7cc7fa3fd6..9580157f4d 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.", @@ -1914,7 +1927,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", @@ -5381,6 +5394,7 @@ pub fn handle_slash_command( | SlashCommand::AddDir { .. } | SlashCommand::History { .. } | SlashCommand::Team { .. } + | SlashCommand::Setup | SlashCommand::Unknown(_) => None, } } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 19571fbec3..0c56d546d3 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -162,10 +162,46 @@ pub struct RuntimeFeatureConfig { trusted_roots: Vec, api_timeout: ApiTimeoutConfig, rules_import: RulesImportConfig, + provider: RuntimeProviderConfig, } /// Controls which external AI coding framework rules are imported into the system prompt. #[derive(Debug, Clone, PartialEq, Eq, Default)] +/// 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() + } +} + pub enum RulesImportConfig { /// Import from all supported frameworks when files are detected. #[default] @@ -764,6 +800,7 @@ fn build_runtime_config( trusted_roots: parse_optional_trusted_roots(&merged_value)?, api_timeout: parse_optional_api_timeout_config(&merged_value)?, rules_import: parse_optional_rules_import(&merged_value)?, + provider: parse_optional_provider_config(&merged_value)?, }; Ok(RuntimeConfig { @@ -891,6 +928,13 @@ impl RuntimeConfig { } impl RuntimeFeatureConfig { + /// Parsed provider configuration (kind, apiKey, baseUrl, model) from + /// merged settings. + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.provider + } + #[must_use] pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self { self.hooks = hooks; @@ -2104,6 +2148,25 @@ fn parse_optional_rules_import(root: &JsonValue) -> Result 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_filesystem_mode_label(value: &str) -> Result { match value { "off" => Ok(FilesystemIsolationMode::Off), diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 297c018efc..ca4ec84a7a 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -92,8 +92,6 @@ enum FieldType { Bool, Object, StringArray, - HookArray, - RulesImport, Number, } @@ -104,8 +102,6 @@ impl FieldType { Self::Bool => "a boolean", Self::Object => "an object", Self::StringArray => "an array of strings", - Self::RulesImport => "a string or an array of strings", - Self::HookArray => "an array of strings or hook objects", Self::Number => "a number", } } @@ -118,13 +114,6 @@ impl FieldType { Self::StringArray => value .as_array() .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())), - Self::HookArray => true, - Self::RulesImport => { - value.as_str().is_some() - || value - .as_array() - .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())) - } Self::Number => value.as_i64().is_some(), } } @@ -213,23 +202,23 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ expected: FieldType::Object, }, FieldSpec { - name: "rulesImport", - expected: FieldType::RulesImport, + name: "subagentModel", + expected: FieldType::String, }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ FieldSpec { name: "PreToolUse", - expected: FieldType::HookArray, + expected: FieldType::StringArray, }, FieldSpec { name: "PostToolUse", - expected: FieldType::HookArray, + expected: FieldType::StringArray, }, FieldSpec { name: "PostToolUseFailure", - expected: FieldType::HookArray, + expected: FieldType::StringArray, }, ]; @@ -421,10 +410,9 @@ fn validate_object_keys( } else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) { // Deprecated key — handled separately, not an unknown-key error. } else { - // Unknown key — preserve compatibility by surfacing it as a warning - // instead of blocking otherwise valid config files. + // Unknown key. let suggestion = suggest_field(key, &known_names); - result.warnings.push(ConfigDiagnostic { + result.errors.push(ConfigDiagnostic { path: path_display.to_string(), field: field_path, line: find_key_line(source, key), @@ -436,56 +424,8 @@ fn validate_object_keys( result } -/// Emit deprecation warnings for bare string hook entries in the hooks object. -/// Legacy `["command-string"]` arrays still load but suggest migration to the -/// structured `{matcher, hooks:[{type, command}]}` form. -fn validate_hook_entry_format( - hooks: &BTreeMap, - source: &str, - path_display: &str, -) -> ValidationResult { - let mut result = ValidationResult { - errors: Vec::new(), - warnings: Vec::new(), - }; - for spec in HOOKS_FIELDS { - let Some(value) = hooks.get(spec.name) else { - continue; - }; - let Some(array) = value.as_array() else { - continue; - }; - for item in array { - if item.as_str().is_some() { - result.warnings.push(ConfigDiagnostic { - path: path_display.to_string(), - field: format!("hooks.{}", spec.name), - line: find_key_line(source, spec.name), - kind: DiagnosticKind::Deprecated { - replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]", - }, - }); - // One deprecation warning per event is enough - break; - } - } - } - result -} - fn suggest_field(input: &str, candidates: &[&str]) -> Option { let input_lower = input.to_ascii_lowercase(); - // #461: prefix-aware matching — if input is a prefix of a candidate, - // treat it as distance 0 (perfect prefix match) to avoid edit-distance - // misranking (e.g., "mcp" → "env" instead of "mcpServers"). - let prefix_match = candidates - .iter() - .filter(|c| c.to_ascii_lowercase().starts_with(&input_lower)) - .min_by_key(|c| c.len()) - .map(|name| name.to_string()); - if prefix_match.is_some() { - return prefix_match; - } candidates .iter() .filter_map(|candidate| { @@ -555,7 +495,6 @@ pub fn validate_config_file( source, &path_display, )); - result.merge(validate_hook_entry_format(hooks, source, &path_display)); } if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) { result.merge(validate_object_keys( @@ -652,11 +591,10 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - assert_eq!(result.warnings[0].field, "unknownField"); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "unknownField"); assert!(matches!( - result.warnings[0].kind, + result.errors[0].kind, DiagnosticKind::UnknownKey { .. } )); } @@ -736,10 +674,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - assert_eq!(result.warnings[0].line, Some(3)); - assert_eq!(result.warnings[0].field, "badKey"); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].line, Some(3)); + assert_eq!(result.errors[0].field, "badKey"); } #[test] @@ -760,7 +697,7 @@ mod tests { #[test] fn validates_nested_hooks_keys() { // given - let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#; + let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#; let parsed = JsonValue::parse(source).expect("valid json"); let object = parsed.as_object().expect("object"); @@ -768,64 +705,8 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!( - result.warnings.len(), - 1, - "expected only the unknown key warning, got {:?}", - result.warnings - ); - assert_eq!(result.warnings[0].field, "hooks.BadHook"); - } - - #[test] - fn validates_object_style_hook_entries() { - let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#; - let parsed = JsonValue::parse(source).expect("valid json"); - let object = parsed.as_object().expect("object"); - - let result = validate_config_file(object, source, &test_path()); - - assert!(result.errors.is_empty(), "{:?}", result.errors); - } - - #[test] - fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() { - let source = r#"{"hooks":{"PreToolUse":[42]}}"#; - let parsed = JsonValue::parse(source).expect("valid json"); - let object = parsed.as_object().expect("object"); - - let result = validate_config_file(object, source, &test_path()); - - assert!(result.errors.is_empty(), "{:?}", result.errors); - } - - #[test] - fn validates_rules_import_string_and_array_forms() { - for source in [ - r#"{"rulesImport":"auto"}"#, - r#"{"rulesImport":"none"}"#, - r#"{"rulesImport":["cursor","copilot"]}"#, - ] { - let parsed = JsonValue::parse(source).expect("valid json"); - let object = parsed.as_object().expect("object"); - - let result = validate_config_file(object, source, &test_path()); - - assert!(result.errors.is_empty(), "{source}: {:?}", result.errors); - } - } - - #[test] - fn rejects_rules_import_wrong_type() { - let source = r#"{"rulesImport":42}"#; - let parsed = JsonValue::parse(source).expect("valid json"); - let object = parsed.as_object().expect("object"); - - let result = validate_config_file(object, source, &test_path()); - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "rulesImport"); + assert_eq!(result.errors[0].field, "hooks.BadHook"); } #[test] @@ -839,9 +720,8 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - assert_eq!(result.warnings[0].field, "permissions.denyAll"); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "permissions.denyAll"); } #[test] @@ -855,9 +735,8 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - assert_eq!(result.warnings[0].field, "sandbox.containerMode"); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "sandbox.containerMode"); } #[test] @@ -871,9 +750,8 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - assert_eq!(result.warnings[0].field, "plugins.autoUpdate"); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "plugins.autoUpdate"); } #[test] @@ -887,9 +765,8 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - assert_eq!(result.warnings[0].field, "oauth.secret"); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "oauth.secret"); } #[test] @@ -897,7 +774,7 @@ mod tests { // given let source = r#"{ "model": "opus", - "hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]}, + "hooks": {"PreToolUse": ["guard"]}, "permissions": {"defaultMode": "plan", "allow": ["Read"]}, "mcpServers": {}, "sandbox": {"enabled": false} @@ -924,9 +801,8 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0].kind { + assert_eq!(result.errors.len(), 1); + match &result.errors[0].kind { DiagnosticKind::UnknownKey { suggestion: Some(s), } => assert_eq!(s, "model"), @@ -937,7 +813,7 @@ mod tests { #[test] fn format_diagnostics_includes_all_entries() { // given - let source = r#"{"model": 42, "badKey": 1}"#; + let source = r#"{"permissionMode": "plan", "badKey": 1}"#; let parsed = JsonValue::parse(source).expect("valid json"); let object = parsed.as_object().expect("object"); let result = validate_config_file(object, source, &test_path()); @@ -949,7 +825,7 @@ mod tests { assert!(output.contains("warning:")); assert!(output.contains("error:")); assert!(output.contains("badKey")); - assert!(output.contains("model")); + assert!(output.contains("permissionMode")); } #[test] diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index e1e0f27d1e..6193527b6f 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -65,14 +65,13 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ - suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError, - ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, - McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, - McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, + 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, - RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, - RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, - ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, + RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig, + RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, @@ -143,9 +142,8 @@ pub use policy_engine::{ PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus, }; pub use prompt::{ - load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile, - ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder, - FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, + load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext, + PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, }; pub use recovery_recipes::{ attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9b1d8a742c..2db02eca64 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -17,6 +17,7 @@ mod init; mod input; mod render; +mod setup_wizard; use std::collections::BTreeSet; use std::env; @@ -1095,6 +1096,7 @@ fn run() -> Result<(), Box> { CliAction::SessionList { output_format } => run_session_list(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. @@ -1238,6 +1240,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 { @@ -1301,6 +1306,7 @@ enum LocalHelpTopic { Model, Settings, Diff, + Setup, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1765,6 +1771,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), @@ -2144,6 +2151,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; @@ -2271,6 +2287,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, @@ -2316,7 +2333,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 { @@ -2336,6 +2353,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), @@ -2390,6 +2408,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), @@ -2452,6 +2471,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 @@ -2744,6 +2764,7 @@ fn suggest_similar_subcommand(input: &str) -> Option> { "status", "sandbox", "doctor", + "setup", "state", "dump-manifests", "bootstrap-plan", @@ -3717,6 +3738,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. /// @@ -6888,7 +6914,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()), } } @@ -8149,6 +8176,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 @@ -10218,6 +10251,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(), } } @@ -10245,6 +10285,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 Date: Wed, 3 Jun 2026 14:24:41 -0500 Subject: [PATCH 2/2] fix: update slash command count and add /setup assertion in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update slash_command_specs().len() assertion from 139 to 140. The /setup command added by this PR increased the spec count by 1 but the test's expected count was not updated, causing CI failure. - Add assert!(help.contains("/setup")) to the renders_help_from_shared_specs test so the new command is verified in the help output. Fixes CI Build ❌ and Test ❌ on #3218. --- rust/crates/commands/src/lib.rs | 3 +- rust/crates/runtime/src/config.rs | 51 +++++++++++++++++-------------- rust/crates/runtime/src/lib.rs | 17 ++++++----- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 9580157f4d..7908691374 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -6011,7 +6011,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 0c56d546d3..9505756357 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -167,6 +167,29 @@ pub struct RuntimeFeatureConfig { /// Controls which external AI coding framework rules are imported into the system prompt. #[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum RulesImportConfig { + /// Import from all supported frameworks when files are detected. + #[default] + Auto, + /// Do not import external framework rules; keep Claw instruction files only. + None, + /// Import only the named frameworks. + List(Vec), +} + +impl RulesImportConfig { + #[must_use] + pub fn should_import(&self, framework: &str) -> bool { + match self { + Self::Auto => true, + Self::None => false, + Self::List(frameworks) => frameworks + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(framework)), + } + } +} + /// Stored provider configuration from the setup wizard. /// /// Represents the `provider` section in `~/.claw/settings.json`, used as a @@ -202,29 +225,6 @@ impl RuntimeProviderConfig { } } -pub enum RulesImportConfig { - /// Import from all supported frameworks when files are detected. - #[default] - Auto, - /// Do not import external framework rules; keep Claw instruction files only. - None, - /// Import only the named frameworks. - List(Vec), -} - -impl RulesImportConfig { - #[must_use] - pub fn should_import(&self, framework: &str) -> bool { - match self { - Self::Auto => true, - Self::None => false, - Self::List(frameworks) => frameworks - .iter() - .any(|candidate| candidate.eq_ignore_ascii_case(framework)), - } - } -} - /// Ordered chain of fallback model identifiers used when the primary /// provider returns a retryable failure (429/500/503/etc.). The chain is /// strict: each entry is tried in order until one succeeds. @@ -915,6 +915,11 @@ impl RuntimeConfig { &self.feature_config.rules_import } + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.feature_config.provider + } + /// Merge config-level default trusted roots with per-call roots. /// /// Config roots are defaults and are kept first; per-call roots extend the diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 6193527b6f..674d89251d 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -66,12 +66,14 @@ pub use compact::{ }; pub use config::{ 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, + suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError, + ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, + McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, + McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, - RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig, - RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, + RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, + RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, + RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, @@ -142,8 +144,9 @@ pub use policy_engine::{ PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus, }; pub use prompt::{ - load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext, - PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, + load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile, + ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder, + FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, }; pub use recovery_recipes::{ attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,