Skip to main content

cc_switch_lib/cli/commands/
provider.rs

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 all providers
37    List,
38    /// Show current provider
39    Current,
40    /// Switch to a provider
41    Switch {
42        /// Provider ID to switch to
43        id: String,
44    },
45    /// Add a new provider (interactive)
46    Add,
47    /// Edit a provider
48    Edit {
49        /// Provider ID to edit
50        id: String,
51    },
52    /// Delete a provider
53    Delete {
54        /// Provider ID to delete
55        id: String,
56    },
57    /// Duplicate a provider
58    Duplicate {
59        /// Provider ID to duplicate
60        id: String,
61    },
62    /// Test provider endpoint speed
63    Speedtest {
64        /// Provider ID to test
65        id: String,
66    },
67    /// Run stream health check for a provider
68    StreamCheck {
69        /// Provider ID to check
70        id: String,
71    },
72    /// Fetch remote model list for a provider
73    FetchModels {
74        /// Provider ID to query
75        id: String,
76    },
77    /// Export a Claude provider to a standalone settings file
78    Export {
79        /// Provider ID to export
80        id: String,
81        /// Output path (default: {cwd}/.claude/settings.local.json)
82        /// If path is a directory, appends settings-{provider-name}.json
83        #[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    // 检查 provider 是否存在
120    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    // 执行切换
126    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    // 检查是否是当前 provider
160    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    // 确认删除
169    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    // 执行删除
183    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    // Disable bracketed paste mode to work around inquire dropping paste events
192    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    // 1. 加载配置和状态
219    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    // 2. 收集基本字段
228    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    // 3. 收集配置
253    let settings_config = prompt_settings_config_for_add(&app_type, add_mode)?;
254
255    // 4. 询问是否配置可选字段
256    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    // 5. 构建 Provider 对象
267    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    // 6. 显示摘要并确认
290    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    // 7. 调用 Service 层
301    ProviderService::add(&state, app_type.clone(), provider)?;
302
303    // 8. 成功消息
304    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    // Disable bracketed paste mode to work around inquire dropping paste events
314    crate::cli::terminal::disable_bracketed_paste_mode_best_effort();
315
316    println!("{}", highlight(&format!("Edit Provider: {}", id)));
317    println!("{}", "=".repeat(50));
318
319    // 1. 加载并验证供应商存在
320    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    // 2. 显示当前配置
337    println!("\n{}", highlight(texts::current_config_header()));
338    display_provider_summary(&original, &app_type);
339    println!();
340
341    // 3. 全量编辑各字段(使用当前值作为默认)
342    println!("{}", info(texts::edit_fields_instruction()));
343
344    // 调用 prompt_basic_fields 来处理基本字段输入(自动使用 initial_value)
345    let (name, website_url) = prompt_basic_fields(Some(&original))?;
346
347    // 4. 询问是否修改配置
348    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    // 5. 询问是否修改可选字段
363    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    // 6. 构建更新后的 Provider(保留 meta 和 created_at)
374    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,                           // 保留元数据
386        in_failover_queue: original.in_failover_queue, // 保留故障转移状态
387    };
388
389    // 7. 显示修改摘要并确认
390    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    // 8. 调用 Service 层
402    ProviderService::update(&state, app_type.clone(), updated)?;
403
404    // 9. 成功消息
405    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    // Single lock scope: get provider AND common_config_snippet together
433    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            // Default: {cwd}/.claude/settings.local.json (auto-loaded by Claude CLI)
463            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            // If path looks like a directory (no .json extension), append settings-{name}.json
470            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 is settings.local.json, Claude CLI will auto-load it
516    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}