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("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
93fn 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 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#[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 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
179pub fn generate_provider_id(name: &str, existing_ids: &[String]) -> String {
182 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 if !existing_ids.contains(&base_id) {
201 return base_id;
202 }
203
204 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
215pub fn prompt_basic_fields(
217 current: Option<&Provider>,
218) -> Result<(String, Option<String>), AppError> {
219 let name = if let Some(provider) = current {
221 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 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 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
268pub 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
330fn prompt_model_field(
342 field_name: &str,
343 env_key: &str,
344 placeholder: &str,
345 current: Option<&Value>,
346) -> Result<Option<String>, AppError> {
347 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 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 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 Ok(None)
375 } else {
376 Ok(None)
378 }
379 } else {
380 Ok(Some(trimmed.to_string()))
382 }
383}
384
385fn 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 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 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 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 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 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
488fn prompt_codex_config(current: Option<&Value>) -> Result<Value, AppError> {
490 println!("\n{}", texts::config_codex_header().bright_cyan().bold());
491
492 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 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 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 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 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
592fn 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
602fn prompt_gemini_config(current: Option<&Value>) -> Result<Value, AppError> {
604 println!("\n{}", texts::config_gemini_header().bright_cyan().bold());
605
606 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, };
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 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 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
680pub 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
738pub 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 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 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
902pub fn current_timestamp() -> i64 {
904 SystemTime::now()
905 .duration_since(UNIX_EPOCH)
906 .unwrap()
907 .as_secs() as i64
908}
909
910fn 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 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
938fn 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}