Skip to main content

cc_switch_lib/cli/commands/
start.rs

1use std::ffi::OsString;
2
3use clap::Subcommand;
4use indexmap::IndexMap;
5
6use crate::app_config::AppType;
7use crate::cli::claude_temp_launch::{
8    ensure_temp_launch_supported, exec_prepared_claude, prepare_launch_from_settings,
9    PreparedClaudeLaunch,
10};
11use crate::cli::codex_temp_launch::{
12    ensure_temp_launch_supported as ensure_codex_temp_launch_supported, exec_prepared_codex,
13    prepare_launch as prepare_codex_launch, PreparedCodexLaunch,
14};
15use crate::error::AppError;
16use crate::provider::Provider;
17use crate::services::ProviderService;
18use crate::store::AppState;
19
20const CLAUDE_RESERVED_NATIVE_ARGS: &[&str] = &["--settings"];
21const CLAUDE_START_AFTER_LONG_HELP: &str = "\
22Examples:
23  cc-switch start claude demo
24  cc-switch start claude demo -- --dangerously-skip-permissions";
25
26const CODEX_START_AFTER_LONG_HELP: &str = "\
27Examples:
28  cc-switch start codex demo
29  cc-switch start codex demo -- --model gpt-5.4";
30
31#[derive(Subcommand)]
32pub enum StartCommand {
33    /// Start Claude with a provider selector without switching the global current provider
34    #[command(after_long_help = CLAUDE_START_AFTER_LONG_HELP)]
35    Claude {
36        /// Provider selector: exact ID first, then exact Name
37        selector: String,
38        /// Native Claude CLI arguments to pass through after `--`
39        #[arg(last = true, value_name = "NATIVE_ARGS")]
40        native_args: Vec<OsString>,
41    },
42    /// Start Codex with a provider selector without switching the global current provider
43    #[command(after_long_help = CODEX_START_AFTER_LONG_HELP)]
44    Codex {
45        /// Provider selector: exact ID first, then exact Name
46        selector: String,
47        /// Native Codex CLI arguments to pass through after `--`
48        #[arg(last = true, value_name = "NATIVE_ARGS")]
49        native_args: Vec<OsString>,
50    },
51}
52
53pub fn execute(cmd: StartCommand) -> Result<(), AppError> {
54    match cmd {
55        StartCommand::Claude {
56            selector,
57            native_args,
58        } => start_claude(&selector, &native_args),
59        StartCommand::Codex {
60            selector,
61            native_args,
62        } => start_codex(&selector, &native_args),
63    }
64}
65
66fn get_state() -> Result<AppState, AppError> {
67    AppState::try_new()
68}
69
70fn start_claude(selector: &str, native_args: &[OsString]) -> Result<(), AppError> {
71    reject_reserved_native_args(native_args, "Claude", CLAUDE_RESERVED_NATIVE_ARGS)?;
72    let state = get_state()?;
73    let providers = ProviderService::list(&state, AppType::Claude)?;
74    let provider = resolve_provider_selector(&providers, selector, "Claude")?;
75
76    ensure_temp_launch_supported()?;
77    let prepared = prepare_claude_launch_with(&state, &provider, &std::env::temp_dir())?;
78    handoff_claude_and_cleanup(&prepared, native_args)
79}
80
81fn start_codex(selector: &str, native_args: &[OsString]) -> Result<(), AppError> {
82    start_with(
83        selector,
84        "Codex",
85        || {
86            let state = get_state()?;
87            ProviderService::list(&state, AppType::Codex)
88        },
89        |provider| {
90            ensure_codex_temp_launch_supported()?;
91            let prepared = prepare_codex_launch(provider, &std::env::temp_dir())?;
92            handoff_codex_and_cleanup(&prepared, native_args)
93        },
94    )
95}
96
97fn reject_reserved_native_args(
98    native_args: &[OsString],
99    app_name: &str,
100    reserved_args: &[&str],
101) -> Result<(), AppError> {
102    let Some(conflicting_arg) = native_args.iter().find(|arg| {
103        reserved_args
104            .iter()
105            .any(|reserved| is_reserved_native_arg(arg, reserved))
106    }) else {
107        return Ok(());
108    };
109
110    let conflicting_arg = conflicting_arg.to_string_lossy();
111    let app_slug = app_name.to_ascii_lowercase();
112    Err(AppError::localized(
113        "cli.start.reserved_native_arg",
114        format!(
115            "`{conflicting_arg}` 不能通过 `cc-switch start {app_slug}` 透传,因为该参数由 cc-switch 管理。"
116        ),
117        format!(
118            "`{conflicting_arg}` cannot be passed through `cc-switch start {app_slug}` because this flag is managed by cc-switch."
119        ),
120    ))
121}
122
123fn is_reserved_native_arg(arg: &OsString, reserved_arg: &str) -> bool {
124    let arg = arg.to_string_lossy();
125    arg == reserved_arg
126        || arg
127            .strip_prefix(reserved_arg)
128            .is_some_and(|suffix| suffix.starts_with('='))
129}
130
131fn start_with<Load, Launch>(
132    selector: &str,
133    app_name: &str,
134    load_providers: Load,
135    launch_provider: Launch,
136) -> Result<(), AppError>
137where
138    Load: FnOnce() -> Result<IndexMap<String, Provider>, AppError>,
139    Launch: FnOnce(&Provider) -> Result<(), AppError>,
140{
141    let providers = load_providers()?;
142    let provider = resolve_provider_selector(&providers, selector, app_name)?;
143    launch_provider(&provider)
144}
145
146fn prepare_claude_launch_with(
147    state: &AppState,
148    provider: &Provider,
149    temp_dir: &std::path::Path,
150) -> Result<PreparedClaudeLaunch, AppError> {
151    let settings = ProviderService::build_effective_live_snapshot_from_state(
152        state,
153        AppType::Claude,
154        provider,
155    )?;
156    prepare_launch_from_settings(&provider.id, &settings, temp_dir)
157}
158
159fn handoff_claude_and_cleanup(
160    prepared: &PreparedClaudeLaunch,
161    native_args: &[OsString],
162) -> Result<(), AppError> {
163    finish_launch(
164        exec_prepared_claude(prepared, native_args),
165        prepared.cleanup_settings_file(),
166        "Claude",
167        "临时设置文件",
168        "temporary settings file",
169        "claude.temp_launch_cleanup_failed",
170    )
171}
172
173fn handoff_codex_and_cleanup(
174    prepared: &PreparedCodexLaunch,
175    native_args: &[OsString],
176) -> Result<(), AppError> {
177    finish_launch(
178        exec_prepared_codex(prepared, native_args),
179        prepared.cleanup_home_dir(),
180        "Codex",
181        "临时配置目录",
182        "temporary config directory",
183        "codex.temp_launch_cleanup_failed",
184    )
185}
186
187fn finish_launch(
188    handoff_result: Result<(), AppError>,
189    cleanup_result: Result<(), AppError>,
190    app_name: &str,
191    cleanup_target_zh: &str,
192    cleanup_target_en: &str,
193    cleanup_failed_key: &'static str,
194) -> Result<(), AppError> {
195    match (handoff_result, cleanup_result) {
196        (Ok(()), Ok(())) => Ok(()),
197        (Ok(()), Err(cleanup_err)) => Err(cleanup_err),
198        (Err(err), Ok(())) => Err(err),
199        (Err(err), Err(cleanup_err)) => Err(AppError::localized(
200            cleanup_failed_key,
201            format!(
202                "启动 {app_name} 失败: {err};同时清理{cleanup_target_zh}失败: {cleanup_err}"
203            ),
204            format!(
205                "Failed to launch {app_name}: {err}; also failed to remove the {cleanup_target_en}: {cleanup_err}"
206            ),
207        )),
208    }
209}
210
211fn resolve_provider_selector(
212    providers: &IndexMap<String, Provider>,
213    selector: &str,
214    app_name: &str,
215) -> Result<Provider, AppError> {
216    if let Some(provider) = providers.get(selector) {
217        return Ok(provider.clone());
218    }
219
220    let exact_name_matches: Vec<_> = providers
221        .values()
222        .filter(|provider| provider.name == selector)
223        .cloned()
224        .collect();
225
226    match exact_name_matches.as_slice() {
227        [] => Err(AppError::localized(
228            "cli.start.provider_selector_not_found",
229            format!(
230                "供应商选择器 '{}' 未匹配到任何 {} 供应商的 ID 或名称",
231                selector, app_name
232            ),
233            format!(
234                "Provider selector '{}' did not match any {} provider by ID or name",
235                selector, app_name
236            ),
237        )),
238        [provider] => Ok(provider.clone()),
239        matches => {
240            let ids = matches
241                .iter()
242                .map(|provider| provider.id.as_str())
243                .collect::<Vec<_>>()
244                .join(", ");
245            Err(AppError::localized(
246                "cli.start.provider_selector_ambiguous",
247                format!(
248                    "供应商选择器 '{}' 存在歧义,匹配到这些 ID: {}",
249                    selector, ids
250                ),
251                format!(
252                    "Provider selector '{}' is ambiguous. Matching IDs: {}",
253                    selector, ids
254                ),
255            ))
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264    use std::ffi::OsString;
265    use tempfile::TempDir;
266
267    fn provider(id: &str, name: &str) -> Provider {
268        Provider::with_id(
269            id.to_string(),
270            name.to_string(),
271            json!({
272                "env": {
273                    "ANTHROPIC_AUTH_TOKEN": format!("sk-{id}")
274                }
275            }),
276            None,
277        )
278    }
279
280    fn state_from_config(config: crate::app_config::MultiAppConfig) -> AppState {
281        let db = std::sync::Arc::new(crate::Database::memory().expect("create memory database"));
282        db.migrate_from_json(&config)
283            .expect("seed memory database from config");
284        AppState {
285            db: db.clone(),
286            config: std::sync::RwLock::new(config),
287            proxy_service: crate::ProxyService::new(db),
288        }
289    }
290
291    #[test]
292    fn claude_rejects_reserved_settings_native_arg() {
293        let err = reject_reserved_native_args(
294            &[OsString::from("--settings")],
295            "Claude",
296            CLAUDE_RESERVED_NATIVE_ARGS,
297        )
298        .expect_err("reserved Claude args should be rejected");
299
300        assert!(err.to_string().contains("--settings"));
301        assert!(err.to_string().contains("cc-switch start claude"));
302    }
303
304    #[test]
305    fn claude_rejects_reserved_settings_equals_native_arg() {
306        let err = reject_reserved_native_args(
307            &[OsString::from("--settings=/tmp/other.json")],
308            "Claude",
309            CLAUDE_RESERVED_NATIVE_ARGS,
310        )
311        .expect_err("reserved Claude args should be rejected");
312
313        assert!(err.to_string().contains("--settings=/tmp/other.json"));
314    }
315
316    #[test]
317    fn codex_allows_non_reserved_native_args() {
318        reject_reserved_native_args(&[OsString::from("--model")], "Codex", &[])
319            .expect("Codex passthrough args should remain allowed");
320    }
321
322    #[test]
323    fn selector_prefers_exact_id_over_name() {
324        let providers = IndexMap::from([
325            ("demo".to_string(), provider("demo", "Shared Name")),
326            ("other".to_string(), provider("other", "demo")),
327        ]);
328
329        let resolved =
330            resolve_provider_selector(&providers, "demo", "Claude").expect("resolve provider");
331
332        assert_eq!(resolved.id, "demo");
333    }
334
335    #[test]
336    fn selector_accepts_exact_name_when_id_is_missing() {
337        let providers = IndexMap::from([("demo".to_string(), provider("demo", "Claude Demo"))]);
338
339        let resolved = resolve_provider_selector(&providers, "Claude Demo", "Claude")
340            .expect("resolve by name");
341
342        assert_eq!(resolved.id, "demo");
343    }
344
345    #[test]
346    fn selector_rejects_ambiguous_names() {
347        let providers = IndexMap::from([
348            ("demo-a".to_string(), provider("demo-a", "Shared Name")),
349            ("demo-b".to_string(), provider("demo-b", "Shared Name")),
350        ]);
351
352        let err = resolve_provider_selector(&providers, "Shared Name", "Claude")
353            .expect_err("ambiguous name should fail");
354
355        assert!(err.to_string().contains("Shared Name"));
356        assert!(err.to_string().contains("demo-a"));
357        assert!(err.to_string().contains("demo-b"));
358    }
359
360    #[test]
361    fn start_claude_launches_resolved_provider() {
362        let providers = IndexMap::from([("demo".to_string(), provider("demo", "Claude Demo"))]);
363        let launched = std::cell::RefCell::new(None::<String>);
364
365        start_with(
366            "Claude Demo",
367            "Claude",
368            || Ok(providers),
369            |provider| {
370                launched.replace(Some(provider.id.clone()));
371                Ok(())
372            },
373        )
374        .expect("start command should launch matching provider");
375
376        assert_eq!(launched.into_inner().as_deref(), Some("demo"));
377    }
378
379    #[test]
380    fn start_codex_launches_resolved_provider() {
381        let providers = IndexMap::from([("demo".to_string(), provider("demo", "Codex Demo"))]);
382        let launched = std::cell::RefCell::new(None::<String>);
383
384        start_with(
385            "Codex Demo",
386            "Codex",
387            || Ok(providers),
388            |provider| {
389                launched.replace(Some(provider.id.clone()));
390                Ok(())
391            },
392        )
393        .expect("start command should launch matching provider");
394
395        assert_eq!(launched.into_inner().as_deref(), Some("demo"));
396    }
397
398    #[test]
399    fn prepare_claude_launch_with_writes_effective_snapshot_from_state() {
400        let temp_dir = TempDir::new().expect("create temp dir");
401        let mut config = crate::app_config::MultiAppConfig::default();
402        config.ensure_app(&AppType::Claude);
403        config.common_config_snippets.claude = Some(
404            r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1,"ANTHROPIC_BASE_URL":"https://common.example"},"includeCoAuthoredBy":false,"permissions":{"allow":["Bash(ls)"]}}"#
405                .to_string(),
406        );
407        let state = state_from_config(config);
408        let provider = Provider::with_id(
409            "demo".to_string(),
410            "Claude Demo".to_string(),
411            json!({
412                "env": {
413                    "ANTHROPIC_AUTH_TOKEN": "sk-demo",
414                    "ANTHROPIC_BASE_URL": "https://provider.example"
415                },
416                "includeCoAuthoredBy": true,
417                "permissions": {
418                    "allow": ["Bash(git status)"]
419                }
420            }),
421            None,
422        );
423
424        let prepared =
425            prepare_claude_launch_with(&state, &provider, temp_dir.path()).expect("prepare launch");
426        let written: serde_json::Value = serde_json::from_str(
427            &std::fs::read_to_string(&prepared.settings_path).expect("read temp settings"),
428        )
429        .expect("parse temp settings");
430        let expected = ProviderService::build_effective_live_snapshot_from_state(
431            &state,
432            AppType::Claude,
433            &provider,
434        )
435        .expect("build effective snapshot");
436
437        assert_eq!(
438            written, expected,
439            "CLI temp launch should write the canonical effective snapshot"
440        );
441    }
442
443    #[test]
444    fn finish_launch_returns_cleanup_error_when_handoff_succeeds() {
445        let err = finish_launch(
446            Ok(()),
447            Err(AppError::Message("cleanup failed".to_string())),
448            "Codex",
449            "临时配置目录",
450            "temporary config directory",
451            "codex.temp_launch_cleanup_failed",
452        )
453        .expect_err("cleanup failure should bubble up");
454
455        assert!(err.to_string().contains("cleanup failed"));
456    }
457
458    #[test]
459    fn finish_launch_combines_handoff_and_cleanup_errors() {
460        let err = finish_launch(
461            Err(AppError::Message("handoff failed".to_string())),
462            Err(AppError::Message("cleanup failed".to_string())),
463            "Codex",
464            "临时配置目录",
465            "temporary config directory",
466            "codex.temp_launch_cleanup_failed",
467        )
468        .expect_err("dual failure should be combined");
469
470        assert!(err.to_string().contains("handoff failed"));
471        assert!(err.to_string().contains("cleanup failed"));
472    }
473}