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 #[command(after_long_help = CLAUDE_START_AFTER_LONG_HELP)]
35 Claude {
36 selector: String,
38 #[arg(last = true, value_name = "NATIVE_ARGS")]
40 native_args: Vec<OsString>,
41 },
42 #[command(after_long_help = CODEX_START_AFTER_LONG_HELP)]
44 Codex {
45 selector: String,
47 #[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}