1use clap::Subcommand;
2use std::path::PathBuf;
3
4use super::provider_inspect;
5use crate::app_config::AppType;
6use crate::cli::commands::provider_input::{
7 current_timestamp, display_provider_summary, generate_provider_id, prompt_basic_fields,
8 prompt_optional_fields, prompt_settings_config, prompt_settings_config_for_add, OptionalFields,
9 ProviderAddMode,
10};
11use crate::cli::i18n::texts;
12use crate::cli::ui::{error, highlight, info, success, warning};
13use crate::error::AppError;
14use crate::provider::{Provider, ProviderMeta};
15use crate::services::ProviderService;
16use crate::store::AppState;
17use inquire::{Confirm, Select, Text};
18
19fn supports_official_provider(app_type: &AppType) -> bool {
20 matches!(app_type, AppType::Codex)
21}
22
23fn is_codex_official_provider(provider: &Provider) -> bool {
24 provider
25 .meta
26 .as_ref()
27 .and_then(|meta| meta.codex_official)
28 .unwrap_or(false)
29 || provider.category.as_deref() == Some("official")
30 || provider.website_url.as_deref() == Some("https://chatgpt.com/codex")
31 || provider.name.trim().eq_ignore_ascii_case("OpenAI Official")
32}
33
34#[derive(Subcommand)]
35pub enum ProviderCommand {
36 List,
38 Current,
40 Switch {
42 id: String,
44 },
45 Add,
47 Edit {
49 id: String,
51 },
52 Delete {
54 id: String,
56 },
57 Duplicate {
59 id: String,
61 },
62 Speedtest {
64 id: String,
66 },
67 StreamCheck {
69 id: String,
71 },
72 FetchModels {
74 id: String,
76 },
77 Export {
79 id: String,
81 #[arg(short, long)]
84 output: Option<PathBuf>,
85 },
86}
87
88pub fn execute(cmd: ProviderCommand, app: Option<AppType>) -> Result<(), AppError> {
89 let app_type = app.unwrap_or(AppType::Claude);
90
91 match cmd {
92 ProviderCommand::List => provider_inspect::list_providers(app_type),
93 ProviderCommand::Current => provider_inspect::show_current(app_type),
94 ProviderCommand::Switch { id } => switch_provider(app_type, &id),
95 ProviderCommand::Add => add_provider(app_type),
96 ProviderCommand::Edit { id } => edit_provider(app_type, &id),
97 ProviderCommand::Delete { id } => delete_provider(app_type, &id),
98 ProviderCommand::Duplicate { id } => duplicate_provider(app_type, &id),
99 ProviderCommand::Speedtest { id } => provider_inspect::speedtest_provider(app_type, &id),
100 ProviderCommand::StreamCheck { id } => {
101 provider_inspect::stream_check_provider(app_type, &id)
102 }
103 ProviderCommand::FetchModels { id } => {
104 provider_inspect::fetch_models_provider(app_type, &id)
105 }
106 ProviderCommand::Export { id, output } => export_provider(app_type, &id, output),
107 }
108}
109
110fn get_state() -> Result<AppState, AppError> {
111 AppState::try_new()
112}
113
114fn switch_provider(app_type: AppType, id: &str) -> Result<(), AppError> {
115 let state = get_state()?;
116 let app_str = app_type.as_str().to_string();
117 let skip_live_sync = !crate::sync_policy::should_sync_live(&app_type);
118
119 let providers = ProviderService::list(&state, app_type.clone())?;
121 let Some(provider) = providers.get(id).cloned() else {
122 return Err(AppError::Message(format!("Provider '{}' not found", id)));
123 };
124
125 ProviderService::switch(&state, app_type.clone(), id)?;
127 if let Err(err) =
128 crate::claude_plugin::sync_claude_plugin_on_provider_switch(&app_type, &provider)
129 {
130 println!(
131 "{}",
132 warning(&texts::claude_plugin_sync_failed_warning(&err.to_string()))
133 );
134 }
135
136 if app_type.is_additive_mode() {
137 println!(
138 "{}",
139 success(&texts::provider_added_to_app_config(id, &app_str))
140 );
141 } else {
142 println!("{}", success(&texts::switched_to_provider(id)));
143 }
144 println!("{}", info(&format!(" Application: {}", app_str)));
145 if skip_live_sync {
146 println!(
147 "{}",
148 warning(&texts::live_sync_skipped_uninitialized_warning(&app_str))
149 );
150 }
151 println!("\n{}", info(texts::restart_note()));
152
153 Ok(())
154}
155
156fn delete_provider(app_type: AppType, id: &str) -> Result<(), AppError> {
157 let state = get_state()?;
158
159 let current_id = ProviderService::current(&state, app_type.clone())?;
161 if id == current_id {
162 return Err(AppError::Message(
163 "Cannot delete the current active provider. Please switch to another provider first."
164 .to_string(),
165 ));
166 }
167
168 let confirm = inquire::Confirm::new(&format!(
170 "Are you sure you want to delete provider '{}'?",
171 id
172 ))
173 .with_default(false)
174 .prompt()
175 .map_err(|e| AppError::Message(format!("Prompt failed: {}", e)))?;
176
177 if !confirm {
178 println!("{}", info("Cancelled."));
179 return Ok(());
180 }
181
182 ProviderService::delete(&state, app_type, id)?;
184
185 println!("{}", success(&format!("✓ Deleted provider '{}'", id)));
186
187 Ok(())
188}
189
190fn add_provider(app_type: AppType) -> Result<(), AppError> {
191 crate::cli::terminal::disable_bracketed_paste_mode_best_effort();
193
194 println!("{}", highlight("Add New Provider"));
195 println!("{}", "=".repeat(50));
196
197 let add_mode = if supports_official_provider(&app_type) {
198 let choices = vec![
199 texts::add_official_provider(),
200 texts::add_third_party_provider(),
201 ];
202 match Select::new(texts::select_provider_add_mode(), choices.clone()).prompt() {
203 Ok(selected) if selected == texts::add_official_provider() => ProviderAddMode::Official,
204 Ok(_selected) => ProviderAddMode::ThirdParty,
205 Err(inquire::error::InquireError::OperationCanceled)
206 | Err(inquire::error::InquireError::OperationInterrupted) => {
207 println!("{}", info(texts::cancelled()));
208 return Ok(());
209 }
210 Err(e) => {
211 return Err(AppError::Message(texts::input_failed_error(&e.to_string())));
212 }
213 }
214 } else {
215 ProviderAddMode::ThirdParty
216 };
217
218 let state = AppState::try_new()?;
220 let config = state.config.read().unwrap();
221 let manager = config
222 .get_manager(&app_type)
223 .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?;
224 let existing_ids: Vec<String> = manager.providers.keys().cloned().collect();
225 drop(config);
226
227 let is_codex_official = matches!(
229 (app_type.clone(), add_mode),
230 (AppType::Codex, ProviderAddMode::Official)
231 );
232 let (name, website_url) = match (app_type.clone(), add_mode) {
233 (AppType::Codex, ProviderAddMode::Official) => {
234 let name = Text::new(texts::provider_name_label())
235 .with_placeholder("OpenAI")
236 .with_help_message(texts::provider_name_help())
237 .prompt()
238 .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?;
239 let name = name.trim().to_string();
240 if name.is_empty() {
241 return Err(AppError::InvalidInput(
242 texts::provider_name_empty_error().to_string(),
243 ));
244 }
245 (name, Some("https://chatgpt.com/codex".to_string()))
246 }
247 _ => prompt_basic_fields(None)?,
248 };
249 let id = generate_provider_id(&name, &existing_ids);
250 println!("{}", info(&texts::generated_id_message(&id)));
251
252 let settings_config = prompt_settings_config_for_add(&app_type, add_mode)?;
254
255 let optional = if Confirm::new(texts::configure_optional_fields_prompt())
257 .with_default(false)
258 .prompt()
259 .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
260 {
261 prompt_optional_fields(None)?
262 } else {
263 OptionalFields::default()
264 };
265
266 let provider = Provider {
268 id: id.clone(),
269 name,
270 settings_config,
271 website_url,
272 category: None,
273 created_at: Some(current_timestamp()),
274 sort_index: optional.sort_index,
275 notes: optional.notes,
276 icon: None,
277 icon_color: None,
278 meta: if is_codex_official {
279 Some(ProviderMeta {
280 codex_official: Some(true),
281 ..Default::default()
282 })
283 } else {
284 None
285 },
286 in_failover_queue: false,
287 };
288
289 display_provider_summary(&provider, &app_type);
291 if !Confirm::new(&texts::confirm_create_entity(texts::entity_provider()))
292 .with_default(false)
293 .prompt()
294 .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
295 {
296 println!("{}", info(texts::cancelled()));
297 return Ok(());
298 }
299
300 ProviderService::add(&state, app_type.clone(), provider)?;
302
303 println!(
305 "\n{}",
306 success(&texts::entity_added_success(texts::entity_provider(), &id))
307 );
308
309 Ok(())
310}
311
312fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> {
313 crate::cli::terminal::disable_bracketed_paste_mode_best_effort();
315
316 println!("{}", highlight(&format!("Edit Provider: {}", id)));
317 println!("{}", "=".repeat(50));
318
319 let state = AppState::try_new()?;
321 let config = state.config.read().unwrap();
322 let manager = config
323 .get_manager(&app_type)
324 .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?;
325 let original = manager
326 .providers
327 .get(id)
328 .ok_or_else(|| {
329 let msg = texts::entity_not_found(texts::entity_provider(), id);
330 AppError::localized("provider.not_found", msg.clone(), msg)
331 })?
332 .clone();
333 let is_current = manager.current == id;
334 drop(config);
335
336 println!("\n{}", highlight(texts::current_config_header()));
338 display_provider_summary(&original, &app_type);
339 println!();
340
341 println!("{}", info(texts::edit_fields_instruction()));
343
344 let (name, website_url) = prompt_basic_fields(Some(&original))?;
346
347 let settings_config = if Confirm::new(texts::modify_provider_config_prompt())
349 .with_default(false)
350 .prompt()
351 .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
352 {
353 prompt_settings_config(
354 &app_type,
355 Some(&original.settings_config),
356 matches!(app_type, AppType::Codex) && is_codex_official_provider(&original),
357 )?
358 } else {
359 original.settings_config.clone()
360 };
361
362 let optional = if Confirm::new(texts::modify_optional_fields_prompt())
364 .with_default(false)
365 .prompt()
366 .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
367 {
368 prompt_optional_fields(Some(&original))?
369 } else {
370 OptionalFields::from_provider(&original)
371 };
372
373 let updated = Provider {
375 id: id.to_string(),
376 name: name.trim().to_string(),
377 settings_config,
378 website_url,
379 category: None,
380 created_at: original.created_at,
381 sort_index: optional.sort_index,
382 notes: optional.notes,
383 icon: None,
384 icon_color: None,
385 meta: original.meta, in_failover_queue: original.in_failover_queue, };
388
389 println!("\n{}", highlight(texts::updated_config_header()));
391 display_provider_summary(&updated, &app_type);
392 if !Confirm::new(&texts::confirm_update_entity(texts::entity_provider()))
393 .with_default(false)
394 .prompt()
395 .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?
396 {
397 println!("{}", info(texts::cancelled()));
398 return Ok(());
399 }
400
401 ProviderService::update(&state, app_type.clone(), updated)?;
403
404 println!(
406 "\n{}",
407 success(&texts::entity_updated_success(texts::entity_provider(), id))
408 );
409 if is_current {
410 println!("{}", warning(texts::current_provider_synced_warning()));
411 }
412
413 Ok(())
414}
415
416fn duplicate_provider(_app_type: AppType, id: &str) -> Result<(), AppError> {
417 println!("{}", info(&format!("Duplicating provider '{}'...", id)));
418 println!("{}", error("Provider duplication is not yet implemented."));
419 Ok(())
420}
421
422fn export_provider(app_type: AppType, id: &str, output: Option<PathBuf>) -> Result<(), AppError> {
423 if !matches!(app_type, AppType::Claude) {
424 return Err(AppError::Message(format!(
425 "Provider export currently supports only Claude standalone settings files. Use --app claude (current app: {}).",
426 app_type.as_str()
427 )));
428 }
429
430 let state = get_state()?;
431
432 let (provider, common_config_snippet) = {
434 let config = state.config.read().map_err(AppError::from)?;
435 let manager = config
436 .get_manager(&app_type)
437 .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?;
438
439 let provider = manager
440 .providers
441 .get(id)
442 .ok_or_else(|| {
443 let msg = texts::provider_not_found(id);
444 AppError::localized("provider.not_found", msg.clone(), msg)
445 })?
446 .clone();
447
448 (
449 provider,
450 config.common_config_snippets.get(&app_type).cloned(),
451 )
452 };
453
454 let apply_common_config = provider
455 .meta
456 .as_ref()
457 .and_then(|meta| meta.apply_common_config)
458 .unwrap_or(true);
459
460 let output_path = match output {
461 None => {
462 std::env::current_dir()
464 .map_err(|e| AppError::Message(format!("无法获取当前工作目录: {}", e)))?
465 .join(".claude")
466 .join("settings.local.json")
467 }
468 Some(path) => {
469 let path_str = path.to_string_lossy();
471 if path_str.ends_with('/') || path_str.ends_with('\\') || !path_str.ends_with(".json") {
472 path.join(format!(
473 "settings-{}.json",
474 crate::config::sanitize_provider_name(&provider.name)
475 ))
476 } else {
477 path
478 }
479 }
480 };
481
482 if output_path.exists() {
483 let confirm = Confirm::new(&format!(
484 "File '{}' already exists. Overwrite?",
485 output_path.display()
486 ))
487 .with_default(false)
488 .prompt()
489 .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?;
490
491 if !confirm {
492 println!("{}", info(texts::cancelled()));
493 return Ok(());
494 }
495 }
496
497 let settings_content = ProviderService::build_live_backup_snapshot(
498 &app_type,
499 &provider,
500 common_config_snippet.as_deref(),
501 apply_common_config,
502 )?;
503
504 crate::config::write_json_file(&output_path, &settings_content)?;
505
506 println!(
507 "{}",
508 success(&format!(
509 "✓ Exported provider '{}' to {}",
510 id,
511 output_path.display()
512 ))
513 );
514
515 if output_path
517 .file_name()
518 .map(|n| n.to_string_lossy() == "settings.local.json")
519 .unwrap_or(false)
520 {
521 println!(
522 "{}",
523 info("Claude CLI will auto-load this config. Just run: claude")
524 );
525 } else {
526 println!(
527 "{}",
528 info(&format!(
529 "Use it with: claude --settings {}",
530 output_path.display()
531 ))
532 );
533 }
534
535 Ok(())
536}