Skip to main content

cc_switch_lib/cli/commands/
provider_input.rs

1// Provider Add/Edit 命令的共享输入逻辑
2// 提供可复用的交互式输入函数,供 add 和 edit 命令使用
3
4use crate::app_config::AppType;
5use crate::cli::i18n::texts;
6use crate::cli::ui::info;
7use crate::error::AppError;
8use crate::provider::Provider;
9use colored::Colorize;
10use inquire::{Confirm, Select, Text};
11use serde_json::{json, Value};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ProviderAddMode {
16    Official,
17    ThirdParty,
18}
19
20#[cfg(test)]
21mod tests {
22    use super::*;
23
24    #[test]
25    fn codex_official_settings_config_uses_upstream_seed_shape() {
26        let cfg = build_codex_official_settings_config(None).expect("build official settings");
27        assert!(
28            cfg.get("auth").is_some(),
29            "official Codex provider should carry auth like upstream snapshots"
30        );
31        assert_eq!(cfg.get("auth"), Some(&json!({})));
32        assert_eq!(cfg.get("config"), Some(&json!("")));
33    }
34
35    #[test]
36    fn codex_official_settings_config_preserves_auth_and_strips_provider_config() {
37        let cfg = build_codex_official_settings_config(Some(&json!({
38            "auth": {
39                "access_token": "oauth-token",
40                "refresh_token": "refresh-token"
41            },
42            "config": "model_provider = \"openai\"\nmodel = \"gpt-5.4\"\nmodel_reasoning_effort = \"high\"\n\n[model_providers.openai]\nbase_url = \"https://api.openai.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n"
43        })))
44        .expect("build official settings");
45
46        assert_eq!(
47            cfg.get("auth"),
48            Some(&json!({
49                "access_token": "oauth-token",
50                "refresh_token": "refresh-token"
51            }))
52        );
53        assert_eq!(
54            cfg.get("config").and_then(Value::as_str),
55            Some("model_reasoning_effort = \"high\"")
56        );
57    }
58
59    #[test]
60    fn build_codex_settings_config_defaults_model_to_gpt_5_4() {
61        let cfg = build_codex_settings_config(
62            Some("sk-test"),
63            "https://api.example.com/v1",
64            "",
65            "responses",
66            "custom",
67        );
68
69        let config = cfg
70            .get("config")
71            .and_then(Value::as_str)
72            .expect("config should be present");
73        assert!(config.contains("model = \"gpt-5.4\""));
74        assert!(config.contains("base_url = \"https://api.example.com/v1\""));
75    }
76}
77
78pub fn prompt_settings_config_for_add(
79    app_type: &AppType,
80    mode: ProviderAddMode,
81) -> Result<Value, AppError> {
82    match (app_type, mode) {
83        (AppType::Claude, _) => prompt_claude_config(None),
84        (AppType::Codex, ProviderAddMode::Official) => prompt_codex_official_config(None),
85        (AppType::Codex, ProviderAddMode::ThirdParty) => prompt_codex_config(None),
86        (AppType::Gemini, _) => prompt_gemini_config(None),
87        (AppType::OpenCode, _) => Ok(json!({})),
88        (AppType::OpenClaw, _) => Ok(json!({})),
89        (AppType::Hermes, _) => Ok(json!({})),
90    }
91}
92
93/// Generate a clean TOML key from a provider name/id for use in model_provider and [model_providers.<key>].
94fn clean_codex_provider_key(raw: &str) -> String {
95    crate::codex_config::clean_codex_provider_key(raw)
96}
97
98fn build_codex_settings_config(
99    api_key: Option<&str>,
100    base_url: &str,
101    model: &str,
102    wire_api: &str,
103    provider_key: &str,
104) -> Value {
105    let model = if model.trim().is_empty() {
106        "gpt-5.4"
107    } else {
108        model.trim()
109    };
110    let base_url = base_url.trim();
111    let provider_key = clean_codex_provider_key(provider_key);
112
113    // Align with upstream: use full config.toml format with [model_providers.<key>]
114    let config_toml = [
115        format!("model_provider = \"{}\"", provider_key),
116        format!("model = \"{}\"", model),
117        "model_reasoning_effort = \"high\"".to_string(),
118        "disable_response_storage = true".to_string(),
119        String::new(),
120        format!("[model_providers.{}]", provider_key),
121        format!("name = \"{}\"", provider_key),
122        format!("base_url = \"{}\"", base_url),
123        format!("wire_api = \"{}\"", wire_api),
124        "requires_openai_auth = true".to_string(),
125        String::new(),
126    ]
127    .join("\n");
128
129    match api_key {
130        Some(key) => json!({
131            "auth": { "OPENAI_API_KEY": key.trim() },
132            "config": config_toml
133        }),
134        None => json!({
135            "config": config_toml
136        }),
137    }
138}
139
140fn build_codex_official_settings_config(current: Option<&Value>) -> Result<Value, AppError> {
141    let auth = current
142        .and_then(|value| value.get("auth"))
143        .and_then(Value::as_object)
144        .map(|value| Value::Object(value.clone()))
145        .unwrap_or_else(|| json!({}));
146    let config = current
147        .and_then(|value| value.get("config"))
148        .and_then(Value::as_str)
149        .unwrap_or("");
150    let cleaned_config = crate::codex_config::strip_codex_provider_config_text(config)?;
151
152    Ok(json!({
153        "auth": auth,
154        "config": cleaned_config
155    }))
156}
157
158/// 可选字段集合
159#[derive(Default)]
160pub struct OptionalFields {
161    pub notes: Option<String>,
162    pub icon: Option<String>,
163    pub icon_color: Option<String>,
164    pub sort_index: Option<usize>,
165}
166
167impl OptionalFields {
168    /// 从现有 Provider 提取可选字段
169    pub fn from_provider(provider: &Provider) -> Self {
170        Self {
171            notes: provider.notes.clone(),
172            icon: provider.icon.clone(),
173            icon_color: provider.icon_color.clone(),
174            sort_index: provider.sort_index,
175        }
176    }
177}
178
179/// 生成唯一的 Provider ID
180/// 基于名称转换为 kebab-case,如有冲突则追加数字后缀
181pub fn generate_provider_id(name: &str, existing_ids: &[String]) -> String {
182    // 转换为 kebab-case
183    let base_id = name
184        .to_lowercase()
185        .chars()
186        .map(|c| {
187            if c.is_alphanumeric() || c == '-' || c == '_' {
188                c
189            } else if c.is_whitespace() {
190                '-'
191            } else {
192                '-'
193            }
194        })
195        .collect::<String>()
196        .trim_matches('-')
197        .to_string();
198
199    // 检查唯一性
200    if !existing_ids.contains(&base_id) {
201        return base_id;
202    }
203
204    // 追加数字后缀
205    let mut counter = 1;
206    loop {
207        let candidate = format!("{}-{}", base_id, counter);
208        if !existing_ids.contains(&candidate) {
209            return candidate;
210        }
211        counter += 1;
212    }
213}
214
215/// 收集基本字段:name, website_url
216pub fn prompt_basic_fields(
217    current: Option<&Provider>,
218) -> Result<(String, Option<String>), AppError> {
219    // 供应商名称:根据上下文选择方法
220    let name = if let Some(provider) = current {
221        // 编辑模式:预填充当前值
222        Text::new(texts::provider_name_label())
223            .with_initial_value(&provider.name)
224            .with_help_message(texts::provider_name_help())
225            .prompt()
226            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
227    } else {
228        // 新增模式:显示示例占位符
229        Text::new(texts::provider_name_label())
230            .with_placeholder("OpenAI")
231            .with_help_message(texts::provider_name_help())
232            .prompt()
233            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
234    };
235
236    let name = name.trim().to_string();
237    if name.is_empty() {
238        return Err(AppError::InvalidInput(
239            texts::provider_name_empty_error().to_string(),
240        ));
241    }
242
243    // 官网 URL:同样处理
244    let website_url = if let Some(provider) = current {
245        let initial = provider.website_url.as_deref().unwrap_or("");
246        Text::new(texts::website_url_label())
247            .with_initial_value(initial)
248            .with_help_message(texts::website_url_help())
249            .prompt()
250            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
251    } else {
252        Text::new(texts::website_url_label())
253            .with_placeholder("https://openai.com")
254            .with_help_message(texts::website_url_help())
255            .prompt()
256            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
257    };
258
259    let website_url = if website_url.trim().is_empty() {
260        None
261    } else {
262        Some(website_url.trim().to_string())
263    };
264
265    Ok((name, website_url))
266}
267
268/// 根据应用类型收集 settings_config
269pub fn prompt_settings_config(
270    app_type: &AppType,
271    current: Option<&Value>,
272    codex_official: bool,
273) -> Result<Value, AppError> {
274    match app_type {
275        AppType::Claude => prompt_claude_config(current),
276        AppType::Codex => {
277            if codex_official {
278                return prompt_codex_official_config(current);
279            }
280
281            let has_auth = current
282                .and_then(|v| v.get("auth"))
283                .and_then(|v| v.as_object())
284                .map(|obj| !obj.is_empty())
285                .unwrap_or(false);
286            let current_config_str = current
287                .and_then(|v| v.get("config"))
288                .and_then(|c| c.as_str());
289            let mut current_base_url: Option<String> = None;
290            if let Some(cfg) = current_config_str {
291                if let Ok(table) = toml::from_str::<toml::Table>(cfg) {
292                    current_base_url = table
293                        .get("base_url")
294                        .and_then(|v| v.as_str())
295                        .map(String::from);
296                    if current_base_url.is_none() {
297                        if let (Some(model_provider), Some(model_providers)) = (
298                            table.get("model_provider").and_then(|v| v.as_str()),
299                            table.get("model_providers").and_then(|v| v.as_table()),
300                        ) {
301                            current_base_url = model_providers
302                                .get(model_provider)
303                                .and_then(|v| v.as_table())
304                                .and_then(|t| t.get("base_url"))
305                                .and_then(|v| v.as_str())
306                                .map(String::from);
307                        }
308                    }
309                }
310            }
311
312            let is_openai_official_endpoint = current_base_url
313                .as_deref()
314                .map(|url| url.trim_start().starts_with("https://api.openai.com"))
315                .unwrap_or(false);
316
317            if !has_auth && is_openai_official_endpoint {
318                prompt_codex_official_config(current)
319            } else {
320                prompt_codex_config(current)
321            }
322        }
323        AppType::Gemini => prompt_gemini_config(current),
324        AppType::OpenCode => Ok(current.cloned().unwrap_or_else(|| json!({}))),
325        AppType::OpenClaw => Ok(current.cloned().unwrap_or_else(|| json!({}))),
326        AppType::Hermes => Ok(current.cloned().unwrap_or_else(|| json!({}))),
327    }
328}
329
330/// 提示用户输入单个模型字段
331///
332/// # 参数
333/// - `field_name`: 字段显示名称(如 "默认模型")
334/// - `env_key`: 环境变量键名(如 "ANTHROPIC_MODEL")
335/// - `placeholder`: 占位符示例值
336/// - `current`: 当前配置(编辑模式)
337///
338/// # 返回
339/// - `Some(value)`: 用户输入了值或需要保留现有值
340/// - `None`: 用户留空且无现有值,不应写入配置
341fn prompt_model_field(
342    field_name: &str,
343    env_key: &str,
344    placeholder: &str,
345    current: Option<&Value>,
346) -> Result<Option<String>, AppError> {
347    // 尝试提取现有值
348    let existing_value = current
349        .and_then(|v| v.get("env"))
350        .and_then(|e| e.get(env_key))
351        .and_then(|m| m.as_str());
352
353    let input = if let Some(existing) = existing_value {
354        // 编辑模式 - 有现有值:预填充
355        Text::new(&format!("{}:", field_name))
356            .with_initial_value(existing)
357            .with_help_message(texts::model_default_help())
358            .prompt()
359            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
360    } else {
361        // 新增模式或编辑模式无现有值:占位符
362        Text::new(&format!("{}:", field_name))
363            .with_placeholder(placeholder)
364            .with_help_message(texts::model_default_help())
365            .prompt()
366            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
367    };
368
369    let trimmed = input.trim();
370
371    if trimmed.is_empty() {
372        if existing_value.is_some() {
373            // 编辑模式下清空 → 移除配置
374            Ok(None)
375        } else {
376            // 新增模式或原本无值 → 不写入
377            Ok(None)
378        }
379    } else {
380        // 有输入值
381        Ok(Some(trimmed.to_string()))
382    }
383}
384
385/// Claude 配置输入
386fn prompt_claude_config(current: Option<&Value>) -> Result<Value, AppError> {
387    println!("\n{}", texts::config_claude_header().bright_cyan().bold());
388
389    let api_key = if let Some(current_key) = current
390        .and_then(|v| v.get("env"))
391        .and_then(|e| e.get("ANTHROPIC_AUTH_TOKEN"))
392        .and_then(|k| k.as_str())
393        .filter(|s| !s.is_empty())
394    {
395        // 编辑模式:显示完整 API Key 供编辑
396        Text::new(texts::api_key_label())
397            .with_initial_value(current_key)
398            .with_help_message(texts::api_key_help())
399            .prompt()
400            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
401    } else {
402        // 新增模式:占位符示例
403        Text::new(texts::api_key_label())
404            .with_placeholder("sk-ant-...")
405            .with_help_message(texts::api_key_help())
406            .prompt()
407            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
408    };
409
410    let base_url = if let Some(current_url) = current
411        .and_then(|v| v.get("env"))
412        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
413        .and_then(|u| u.as_str())
414        .filter(|s| !s.is_empty())
415    {
416        Text::new(texts::base_url_label())
417            .with_initial_value(current_url)
418            .with_help_message(texts::api_key_help())
419            .prompt()
420            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
421    } else {
422        Text::new(texts::base_url_label())
423            .with_placeholder(texts::base_url_placeholder())
424            .with_help_message(texts::api_key_help())
425            .prompt()
426            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
427    };
428
429    // 询问是否配置模型
430    let config_models = Confirm::new(texts::configure_model_names_prompt())
431        .with_default(false)
432        .with_help_message(texts::api_key_help())
433        .prompt()
434        .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?;
435
436    let mut env = serde_json::Map::new();
437    env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(api_key.trim()));
438    env.insert("ANTHROPIC_BASE_URL".to_string(), json!(base_url.trim()));
439
440    if config_models {
441        // 使用新的辅助函数处理四个模型字段
442        let model = prompt_model_field(
443            texts::model_default_label(),
444            "ANTHROPIC_MODEL",
445            texts::model_sonnet_placeholder(),
446            current,
447        )?;
448
449        let haiku = prompt_model_field(
450            texts::model_haiku_label(),
451            "ANTHROPIC_DEFAULT_HAIKU_MODEL",
452            texts::model_haiku_placeholder(),
453            current,
454        )?;
455
456        let sonnet = prompt_model_field(
457            texts::model_sonnet_label(),
458            "ANTHROPIC_DEFAULT_SONNET_MODEL",
459            texts::model_sonnet_placeholder(),
460            current,
461        )?;
462
463        let opus = prompt_model_field(
464            texts::model_opus_label(),
465            "ANTHROPIC_DEFAULT_OPUS_MODEL",
466            texts::model_opus_placeholder(),
467            current,
468        )?;
469
470        // 条件写入:只在值存在时写入配置
471        if let Some(value) = model {
472            env.insert("ANTHROPIC_MODEL".to_string(), json!(value));
473        }
474        if let Some(value) = haiku {
475            env.insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), json!(value));
476        }
477        if let Some(value) = sonnet {
478            env.insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), json!(value));
479        }
480        if let Some(value) = opus {
481            env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), json!(value));
482        }
483    }
484
485    Ok(json!({ "env": env }))
486}
487
488/// Codex 配置输入(第三方/自定义:需要 API Key)
489fn prompt_codex_config(current: Option<&Value>) -> Result<Value, AppError> {
490    println!("\n{}", texts::config_codex_header().bright_cyan().bold());
491
492    // 从当前配置提取值
493    let current_api_key = current
494        .and_then(|v| v.get("auth"))
495        .and_then(|a| a.get("OPENAI_API_KEY"))
496        .and_then(|k| k.as_str())
497        .filter(|s| !s.is_empty());
498
499    let current_config_str = current
500        .and_then(|v| v.get("config"))
501        .and_then(|c| c.as_str());
502
503    let mut current_base_url: Option<String> = None;
504    let mut current_model: Option<String> = None;
505    if let Some(cfg) = current_config_str {
506        if let Ok(table) = toml::from_str::<toml::Table>(cfg) {
507            current_base_url = table
508                .get("base_url")
509                .and_then(|v| v.as_str())
510                .map(String::from);
511            if current_base_url.is_none() {
512                // Full upstream-style config: base_url lives under model_providers.<model_provider>.
513                if let (Some(model_provider), Some(model_providers)) = (
514                    table.get("model_provider").and_then(|v| v.as_str()),
515                    table.get("model_providers").and_then(|v| v.as_table()),
516                ) {
517                    current_base_url = model_providers
518                        .get(model_provider)
519                        .and_then(|v| v.as_table())
520                        .and_then(|t| t.get("base_url"))
521                        .and_then(|v| v.as_str())
522                        .map(String::from);
523                }
524            }
525            current_model = table
526                .get("model")
527                .and_then(|v| v.as_str())
528                .map(String::from);
529        }
530    }
531
532    // 1. API Key(恢复:用于旧版本 Codex 兼容性)
533    let api_key = if let Some(current_key) = current_api_key {
534        Text::new(texts::openai_api_key_label())
535            .with_initial_value(current_key)
536            .with_help_message(texts::api_key_help())
537            .prompt()
538            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
539    } else {
540        Text::new(texts::openai_api_key_label())
541            .with_placeholder("sk-...")
542            .with_help_message(texts::api_key_help())
543            .prompt()
544            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
545    };
546
547    // 2. Base URL
548    let base_url = if let Some(current) = current_base_url.as_deref() {
549        Text::new(&format!("{}:", texts::tui_label_base_url()))
550            .with_initial_value(current)
551            .with_help_message("API endpoint (e.g., https://api.openai.com/v1)")
552            .prompt()
553            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
554    } else {
555        Text::new(&format!("{}:", texts::tui_label_base_url()))
556            .with_placeholder("https://api.openai.com/v1")
557            .with_help_message("API endpoint")
558            .prompt()
559            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
560    };
561    let base_url = base_url.trim().to_string();
562    if base_url.is_empty() {
563        return Err(AppError::InvalidInput(
564            texts::base_url_empty_error().to_string(),
565        ));
566    }
567
568    // 3. Model
569    let model = if let Some(current) = current_model.as_deref() {
570        Text::new(&format!("{}:", texts::model_label()))
571            .with_initial_value(current)
572            .with_help_message("Model name (e.g., gpt-5.4, o3)")
573            .prompt()
574            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
575    } else {
576        Text::new(&format!("{}:", texts::model_label()))
577            .with_placeholder("gpt-5.4")
578            .with_help_message("Model name")
579            .prompt()
580            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
581    };
582
583    Ok(build_codex_settings_config(
584        Some(api_key.trim()),
585        &base_url,
586        model.trim(),
587        "responses",
588        "custom",
589    ))
590}
591
592/// Codex 配置输入(官方:仍写入 provider snapshot 的 auth/config)
593fn prompt_codex_official_config(current: Option<&Value>) -> Result<Value, AppError> {
594    println!("\n{}", texts::config_codex_header().bright_cyan().bold());
595    println!(
596        "{}",
597        info("OpenAI Official keeps the stored auth snapshot and uses the upstream empty official config.")
598    );
599    build_codex_official_settings_config(current)
600}
601
602/// Gemini 配置输入(含认证类型选择)
603fn prompt_gemini_config(current: Option<&Value>) -> Result<Value, AppError> {
604    println!("\n{}", texts::config_gemini_header().bright_cyan().bold());
605
606    // 检测当前认证类型
607    let current_auth_type = detect_gemini_auth_type(current);
608    let default_index = match current_auth_type.as_deref() {
609        Some("oauth") => 0,
610        _ => 1, // 默认 Generic API Key(包括 packycode 和 generic)
611    };
612
613    let auth_options = vec![texts::google_oauth_official(), texts::generic_api_key()];
614
615    let auth_type = Select::new(texts::auth_type_label(), auth_options.clone())
616        .with_starting_cursor(default_index)
617        .with_help_message(texts::select_auth_method_help())
618        .prompt()
619        .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?;
620
621    // Match using the translated strings
622    let google_oauth = texts::google_oauth_official();
623
624    if auth_type == google_oauth {
625        println!("{}", texts::use_google_oauth_warning().yellow());
626        Ok(json!({
627            "env": {},
628            "config": {}
629        }))
630    } else {
631        // Generic API Key (统一处理所有 API Key 供应商,包括 PackyCode)
632        let api_key = if let Some(current_key) = current
633            .and_then(|v| v.get("env"))
634            .and_then(|e| e.get("GEMINI_API_KEY"))
635            .and_then(|k| k.as_str())
636            .filter(|s| !s.is_empty())
637        {
638            Text::new(texts::gemini_api_key_label())
639                .with_initial_value(current_key)
640                .with_help_message(texts::generic_api_key_help())
641                .prompt()
642                .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
643        } else {
644            Text::new(texts::gemini_api_key_label())
645                .with_placeholder("AIza... or pk-...")
646                .with_help_message(texts::generic_api_key_help())
647                .prompt()
648                .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
649        };
650
651        let base_url = if let Some(current_url) = current
652            .and_then(|v| v.get("env"))
653            .and_then(|e| e.get("GOOGLE_GEMINI_BASE_URL"))
654            .and_then(|u| u.as_str())
655            .filter(|s| !s.is_empty())
656        {
657            Text::new(texts::gemini_base_url_label())
658                .with_initial_value(current_url)
659                .with_help_message(texts::gemini_base_url_help())
660                .prompt()
661                .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
662        } else {
663            Text::new(texts::gemini_base_url_label())
664                .with_placeholder(texts::gemini_base_url_placeholder())
665                .with_help_message(texts::gemini_base_url_help())
666                .prompt()
667                .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
668        };
669
670        Ok(json!({
671            "env": {
672                "GEMINI_API_KEY": api_key.trim(),
673                "GOOGLE_GEMINI_BASE_URL": base_url.trim()
674            },
675            "config": {}
676        }))
677    }
678}
679
680/// 收集可选字段
681pub fn prompt_optional_fields(current: Option<&Provider>) -> Result<OptionalFields, AppError> {
682    println!("\n{}", texts::optional_fields_config().bright_cyan().bold());
683
684    let notes = if let Some(provider) = current {
685        let initial = provider.notes.as_deref().unwrap_or("");
686        Text::new(texts::notes_label())
687            .with_initial_value(initial)
688            .with_help_message(texts::notes_help_edit())
689            .prompt()
690            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
691    } else {
692        Text::new(texts::notes_label())
693            .with_placeholder(texts::notes_example_placeholder())
694            .with_help_message(texts::notes_help_new())
695            .prompt()
696            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
697    };
698    let notes = if notes.trim().is_empty() {
699        None
700    } else {
701        Some(notes.trim().to_string())
702    };
703
704    let sort_index_str = if let Some(provider) = current {
705        let initial = provider
706            .sort_index
707            .map(|i| i.to_string())
708            .unwrap_or_default();
709        Text::new(texts::sort_index_label())
710            .with_initial_value(&initial)
711            .with_help_message(texts::sort_index_help_edit())
712            .prompt()
713            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
714    } else {
715        Text::new(texts::sort_index_label())
716            .with_placeholder(texts::sort_index_placeholder())
717            .with_help_message(texts::sort_index_help_new())
718            .prompt()
719            .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
720    };
721    let sort_index =
722        if sort_index_str.trim().is_empty() {
723            None
724        } else {
725            Some(sort_index_str.trim().parse::<usize>().map_err(|_| {
726                AppError::InvalidInput(texts::invalid_sort_index_number().to_string())
727            })?)
728        };
729
730    Ok(OptionalFields {
731        notes,
732        icon: None,
733        icon_color: None,
734        sort_index,
735    })
736}
737
738/// 显示供应商配置摘要
739pub fn display_provider_summary(provider: &Provider, app_type: &AppType) {
740    println!(
741        "\n{}",
742        texts::provider_config_summary().bright_green().bold()
743    );
744    println!("{}: {}", texts::id_label().bright_yellow(), provider.id);
745    println!(
746        "{}: {}",
747        texts::provider_name_label().bright_yellow(),
748        provider.name
749    );
750
751    if let Some(website) = &provider.website_url {
752        println!("{}: {}", texts::website_label().bright_yellow(), website);
753    }
754
755    // 显示关键配置(不显示完整 API Key)
756    println!("\n{}", texts::core_config_label().bright_cyan());
757    match app_type {
758        AppType::Claude => {
759            if let Some(env) = provider.settings_config.get("env") {
760                if let Some(api_key) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
761                    println!(
762                        "  {}: {}",
763                        texts::api_key_display_label(),
764                        mask_api_key(api_key)
765                    );
766                }
767                if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
768                    println!("  {}: {}", texts::base_url_display_label(), base_url);
769                }
770                if let Some(model) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
771                    println!("  {}: {}", texts::model_label(), model);
772                }
773            }
774        }
775        AppType::Codex => {
776            if let Some(auth) = provider.settings_config.get("auth") {
777                if let Some(api_key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
778                    println!(
779                        "  {}: {}",
780                        texts::api_key_display_label(),
781                        mask_api_key(api_key)
782                    );
783                }
784            }
785            if let Some(config) = provider
786                .settings_config
787                .get("config")
788                .and_then(|v| v.as_str())
789            {
790                println!("  {}", texts::config_toml_lines(config.lines().count()));
791            }
792        }
793        AppType::Gemini => {
794            if let Some(env) = provider.settings_config.get("env") {
795                if let Some(api_key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
796                    println!(
797                        "  {}: {}",
798                        texts::api_key_display_label(),
799                        mask_api_key(api_key)
800                    );
801                }
802                if let Some(base_url) = env
803                    .get("GOOGLE_GEMINI_BASE_URL")
804                    .or_else(|| env.get("BASE_URL"))
805                    .and_then(|v| v.as_str())
806                {
807                    println!("  {}: {}", texts::base_url_display_label(), base_url);
808                }
809            }
810        }
811        AppType::OpenCode => {
812            if let Some(options) = provider.settings_config.get("options") {
813                if let Some(api_key) = options.get("apiKey").and_then(|v| v.as_str()) {
814                    println!(
815                        "  {}: {}",
816                        texts::api_key_display_label(),
817                        mask_api_key(api_key)
818                    );
819                }
820                if let Some(base_url) = options.get("baseURL").and_then(|v| v.as_str()) {
821                    println!("  {}: {}", texts::base_url_display_label(), base_url);
822                }
823            }
824            if let Some(models) = provider
825                .settings_config
826                .get("models")
827                .and_then(|v| v.as_object())
828            {
829                println!("  {}: {}", texts::model_label(), models.len());
830            }
831        }
832        AppType::OpenClaw => {
833            if let Some(api_key) = provider
834                .settings_config
835                .get("apiKey")
836                .and_then(|v| v.as_str())
837            {
838                println!(
839                    "  {}: {}",
840                    texts::api_key_display_label(),
841                    mask_api_key(api_key)
842                );
843            }
844            if let Some(base_url) = provider
845                .settings_config
846                .get("baseUrl")
847                .and_then(|v| v.as_str())
848            {
849                println!("  {}: {}", texts::base_url_display_label(), base_url);
850            }
851            if let Some(models) = provider
852                .settings_config
853                .get("models")
854                .and_then(|v| v.as_array())
855            {
856                println!("  {}: {}", texts::model_label(), models.len());
857            }
858        }
859        AppType::Hermes => {
860            if let Some(api_key) = provider
861                .settings_config
862                .get("apiKey")
863                .and_then(|v| v.as_str())
864            {
865                println!(
866                    "  {}: {}",
867                    texts::api_key_display_label(),
868                    mask_api_key(api_key)
869                );
870            }
871            if let Some(base_url) = provider
872                .settings_config
873                .get("baseUrl")
874                .and_then(|v| v.as_str())
875            {
876                println!("  {}: {}", texts::base_url_display_label(), base_url);
877            }
878            if let Some(models) = provider
879                .settings_config
880                .get("models")
881                .and_then(|v| v.as_array())
882            {
883                println!("  {}: {}", texts::model_label(), models.len());
884            }
885        }
886    }
887
888    // 可选字段
889    if provider.notes.is_some() || provider.sort_index.is_some() {
890        println!("\n{}", texts::optional_fields_label().bright_cyan());
891        if let Some(notes) = &provider.notes {
892            println!("  {}: {}", texts::notes_label_colon(), notes);
893        }
894        if let Some(idx) = provider.sort_index {
895            println!("  {}: {}", texts::sort_index_label_colon(), idx);
896        }
897    }
898
899    println!("{}", texts::summary_divider().bright_green().bold());
900}
901
902/// 获取当前时间戳(秒)
903pub fn current_timestamp() -> i64 {
904    SystemTime::now()
905        .duration_since(UNIX_EPOCH)
906        .unwrap()
907        .as_secs() as i64
908}
909
910// ========== 辅助函数 ==========
911/// 检测 Gemini 当前的认证类型
912fn detect_gemini_auth_type(value: Option<&Value>) -> Option<String> {
913    if let Some(env) = value.and_then(|v| v.get("env")) {
914        if env.get("GEMINI_API_KEY").is_some() {
915            if env
916                .get("GOOGLE_GEMINI_BASE_URL")
917                .and_then(|v| v.as_str())
918                .map(|s| s.contains("packycode"))
919                .unwrap_or(false)
920            {
921                return Some("packycode".to_string());
922            } else {
923                return Some("generic".to_string());
924            }
925        }
926    }
927    // 如果没有 API Key,假设是 OAuth
928    if value
929        .and_then(|v| v.get("env"))
930        .map(|v| v.as_object().map(|o| o.is_empty()).unwrap_or(true))
931        .unwrap_or(true)
932    {
933        return Some("oauth".to_string());
934    }
935    None
936}
937
938/// 遮蔽 API Key 显示(用于摘要显示)
939fn mask_api_key(key: &str) -> String {
940    if key.len() <= 8 {
941        return "***".to_string();
942    }
943    format!("{}...{}", &key[..4], &key[key.len() - 4..])
944}