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