1use 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
98fn 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 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#[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 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
187pub fn generate_provider_id(name: &str, existing_ids: &[String]) -> String {
190 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 if !existing_ids.contains(&base_id) {
209 return base_id;
210 }
211
212 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
223pub fn prompt_basic_fields(
225 current: Option<&Provider>,
226) -> Result<(String, Option<String>), AppError> {
227 let name = if let Some(provider) = current {
229 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 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 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
276pub 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
337fn prompt_model_field(
349 field_name: &str,
350 env_key: &str,
351 placeholder: &str,
352 current: Option<&Value>,
353) -> Result<Option<String>, AppError> {
354 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 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 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 Ok(None)
382 } else {
383 Ok(None)
385 }
386 } else {
387 Ok(Some(trimmed.to_string()))
389 }
390}
391
392fn 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 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 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 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 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 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
495fn prompt_codex_config(current: Option<&Value>) -> Result<Value, AppError> {
497 println!("\n{}", texts::config_codex_header().bright_cyan().bold());
498
499 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 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 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 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 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
598fn 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
608fn prompt_gemini_config(current: Option<&Value>) -> Result<Value, AppError> {
610 println!("\n{}", texts::config_gemini_header().bright_cyan().bold());
611
612 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, };
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 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 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
686pub 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
744pub 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 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 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
906pub fn current_timestamp() -> i64 {
908 SystemTime::now()
909 .duration_since(UNIX_EPOCH)
910 .unwrap()
911 .as_secs() as i64
912}
913
914fn 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 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
942fn 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}