1use serde::Deserialize;
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::paths;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum AliasProvider {
10 Anthropic,
11 Codex,
12 Kimi,
13}
14
15impl AliasProvider {
16 pub fn as_str(&self) -> &str {
17 match self {
18 AliasProvider::Anthropic => "anthropic",
19 AliasProvider::Codex => "codex",
20 AliasProvider::Kimi => "kimi",
21 }
22 }
23}
24
25#[derive(Debug, Clone)]
26pub struct LoadedConfig {
27 pub port: u16,
28 pub alias_provider: AliasProvider,
29 pub log_verbose: bool,
30 pub log_stderr: bool,
31 pub config_dir: PathBuf,
32}
33
34#[derive(Deserialize)]
35struct FileConfig {
36 pub port: Option<u16>,
37 #[serde(rename = "aliasProvider")]
38 pub alias_provider: Option<String>,
39 pub log: Option<FileLog>,
40 pub kimi: Option<KimiConfig>,
41 pub codex: Option<CodexConfig>,
42 pub cursor: Option<CursorConfig>,
43 pub grok: Option<GrokConfig>,
44}
45
46#[derive(Deserialize, Clone)]
47struct CodexConfig {
48 #[serde(rename = "baseUrl")]
49 pub base_url: Option<String>,
50 #[serde(rename = "originator")]
51 pub originator: Option<String>,
52 #[serde(rename = "userAgent")]
53 pub user_agent: Option<String>,
54 #[serde(rename = "previousResponseId")]
55 pub previous_response_id: Option<bool>,
56 #[serde(rename = "serviceTier")]
57 pub service_tier: Option<String>,
58 #[serde(rename = "reasoningSummary")]
59 pub reasoning_summary: Option<String>,
60 #[serde(rename = "effort")]
61 pub effort: Option<String>,
62 #[serde(rename = "model")]
63 pub model: Option<String>,
64 pub transport: Option<String>,
65}
66
67#[derive(Deserialize, Clone)]
68struct CursorConfig {
69 #[serde(rename = "baseUrl")]
70 pub base_url: Option<String>,
71 #[serde(rename = "clientVersion")]
72 pub client_version: Option<String>,
73 #[serde(rename = "agentBundle")]
74 pub agent_bundle: Option<String>,
75}
76
77#[derive(Deserialize, Clone)]
78struct KimiConfig {
79 #[serde(rename = "userAgent")]
80 pub user_agent: Option<String>,
81 #[serde(rename = "oauthHost")]
82 pub oauth_host: Option<String>,
83 #[serde(rename = "baseUrl")]
84 pub base_url: Option<String>,
85}
86
87#[derive(Deserialize, Clone)]
88struct GrokConfig {
89 #[serde(rename = "baseUrl")]
90 pub base_url: Option<String>,
91 #[serde(rename = "clientVersion")]
92 pub client_version: Option<String>,
93}
94
95#[derive(Deserialize)]
96struct FileLog {
97 pub verbose: Option<bool>,
98 pub stderr: Option<bool>,
99}
100
101fn parse_alias(raw: &str) -> Option<AliasProvider> {
102 match raw {
103 "anthropic" => Some(AliasProvider::Anthropic),
104 "codex" => Some(AliasProvider::Codex),
105 "kimi" => Some(AliasProvider::Kimi),
106 _ => None,
107 }
108}
109
110fn read_file_config(config_dir: &Path) -> Option<FileConfig> {
111 let path = config_dir.join("config.json");
112 let raw = fs::read_to_string(path).ok()?;
113 serde_json::from_str(&raw).ok()
114}
115
116pub fn load_config() -> LoadedConfig {
117 let config_dir = paths::config_dir();
118 let file = read_file_config(&config_dir);
119 let env: HashMap<_, _> = std::env::vars().collect();
120
121 let mut out = LoadedConfig {
122 port: 18765,
123 alias_provider: AliasProvider::Anthropic,
124 log_verbose: false,
125 log_stderr: false,
126 config_dir: config_dir.clone(),
127 };
128
129 if let Some(raw) = env.get("CCP_ALIAS_PROVIDER") {
130 if let Some(alias) = parse_alias(raw) {
131 out.alias_provider = alias;
132 }
133 } else if let Some(alias_provider) = file
134 .as_ref()
135 .and_then(|f| f.alias_provider.as_deref())
136 .and_then(parse_alias)
137 {
138 out.alias_provider = alias_provider;
139 }
140
141 if let Some(raw) = env.get("PORT") {
142 if let Ok(port) = raw.parse::<u16>() {
143 out.port = port;
144 }
145 } else if let Some(port) = file.as_ref().and_then(|f| f.port) {
146 out.port = port;
147 }
148
149 if env.contains_key("CCP_LOG_VERBOSE") {
150 out.log_verbose = true;
151 } else if let Some(value) = file
152 .as_ref()
153 .and_then(|f| f.log.as_ref().and_then(|v| v.verbose))
154 {
155 out.log_verbose = value;
156 }
157
158 if env.contains_key("CCP_LOG_STDERR") {
159 out.log_stderr = true;
160 } else if let Some(value) = file
161 .as_ref()
162 .and_then(|f| f.log.as_ref().and_then(|v| v.stderr))
163 {
164 out.log_stderr = value;
165 }
166
167 out
168}
169
170pub fn config_path() -> PathBuf {
171 paths::config_dir().join("config.json")
172}
173
174pub fn port() -> u16 {
175 load_config().port
176}
177
178pub fn alias_provider() -> AliasProvider {
179 load_config().alias_provider
180}
181
182pub fn log_verbose() -> bool {
183 load_config().log_verbose
184}
185
186pub fn log_stderr() -> bool {
187 load_config().log_stderr
188}
189
190pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
191 let file = read_file_config(&cfg.config_dir);
192 let env: HashMap<_, _> = std::env::vars().collect();
193 let mut out = Vec::new();
194 if env.contains_key("PORT") {
195 out.push("port (env)".to_string());
196 }
197 if env.contains_key("CCP_ALIAS_PROVIDER") {
198 out.push("aliasProvider (env)".to_string());
199 }
200 if env.contains_key("CCP_LOG_VERBOSE") {
201 out.push("log.verbose (env)".to_string());
202 }
203 if env.contains_key("CCP_LOG_STDERR") {
204 out.push("log.stderr (env)".to_string());
205 }
206 if env.contains_key("CCP_KIMI_OAUTH_HOST") {
207 out.push("kimi.oauthHost (env)".to_string());
208 }
209 if env.contains_key("CCP_KIMI_BASE_URL") {
210 out.push("kimi.baseUrl (env)".to_string());
211 }
212 if env.contains_key("CCP_CURSOR_BASE_URL") {
213 out.push("cursor.baseUrl (env)".to_string());
214 }
215 if env.contains_key("CCP_CURSOR_CLIENT_VERSION") {
216 out.push("cursor.clientVersion (env)".to_string());
217 }
218 if env.contains_key("CCP_KIMI_USER_AGENT") {
219 out.push("kimi.userAgent (env)".to_string());
220 }
221 if env.contains_key("CCP_GROK_BASE_URL") {
222 out.push("grok.baseUrl (env)".to_string());
223 }
224 if env.contains_key("CCP_GROK_CLIENT_VERSION") {
225 out.push("grok.clientVersion (env)".to_string());
226 }
227 if env
228 .get("CCP_CODEX_REASONING_SUMMARY")
229 .is_some_and(|raw| !raw.is_empty())
230 {
231 out.push("CCP_CODEX_REASONING_SUMMARY (env)".to_string());
232 }
233 if let Some(file_cfg) = file {
234 if let Some(p) = file_cfg.port {
235 out.push(format!("port: {p}"));
236 }
237 if let Some(alias) = file_cfg.alias_provider {
238 out.push(format!("aliasProvider: {alias}"));
239 }
240 if let Some(log) = file_cfg.log {
241 if let Some(v) = log.verbose {
242 out.push(format!("log.verbose: {v}"));
243 }
244 if let Some(v) = log.stderr {
245 out.push(format!("log.stderr: {v}"));
246 }
247 }
248 if let Some(codex) = file_cfg.codex
249 && let Some(summary) = codex.reasoning_summary
250 && !summary.is_empty()
251 {
252 out.push("codex.reasoningSummary (config)".to_string());
253 }
254 }
255 out
256}
257
258pub fn grok_base_url() -> String {
259 let env: HashMap<_, _> = std::env::vars().collect();
260 if let Some(raw) = env.get("CCP_GROK_BASE_URL") {
261 return raw.clone();
262 }
263 if let Some(grok) = read_file_config(&paths::config_dir()).and_then(|f| f.grok)
264 && let Some(url) = grok.base_url
265 {
266 return url;
267 }
268 "https://cli-chat-proxy.grok.com/v1".to_string()
269}
270
271pub fn grok_client_version() -> String {
272 let env: HashMap<_, _> = std::env::vars().collect();
273 if let Some(raw) = env.get("CCP_GROK_CLIENT_VERSION") {
274 return raw.clone();
275 }
276 if let Some(grok) = read_file_config(&paths::config_dir()).and_then(|f| f.grok)
277 && let Some(version) = grok.client_version
278 {
279 return version;
280 }
281 "0.2.93".to_string()
282}
283
284pub fn is_verbose() -> bool {
285 log_verbose()
286}
287
288pub fn anthropic_base_url() -> String {
289 let env: HashMap<_, _> = std::env::vars().collect();
290 if let Some(raw) = env.get("CCP_ANTHROPIC_BASE_URL") {
291 return raw.clone();
292 }
293 "https://api.anthropic.com".to_string()
294}
295
296pub fn kimi_oauth_host() -> String {
297 let env: HashMap<_, _> = std::env::vars().collect();
298 if let Some(raw) = env.get("CCP_KIMI_OAUTH_HOST") {
299 return raw.clone();
300 }
301 let config_dir = paths::config_dir();
302 if let Some(file) = read_file_config(&config_dir)
303 && let Some(kimi) = file.kimi
304 && let Some(host) = kimi.oauth_host
305 {
306 return host;
307 }
308 "https://auth.kimi.com".to_string()
309}
310
311pub fn kimi_base_url() -> String {
312 let env: HashMap<_, _> = std::env::vars().collect();
313 if let Some(raw) = env.get("CCP_KIMI_BASE_URL") {
314 return raw.clone();
315 }
316 let config_dir = paths::config_dir();
317 if let Some(file) = read_file_config(&config_dir)
318 && let Some(kimi) = file.kimi
319 && let Some(url) = kimi.base_url
320 {
321 return url;
322 }
323 "https://api.kimi.com/coding/v1".to_string()
324}
325
326pub fn kimi_user_agent(default: &str) -> String {
327 let env: HashMap<_, _> = std::env::vars().collect();
328 if let Some(raw) = env.get("CCP_KIMI_USER_AGENT") {
329 return raw.clone();
330 }
331 if let Some(raw) = env.get("CCP_USER_AGENT") {
332 return raw.clone();
333 }
334 let config_dir = paths::config_dir();
335 if let Some(file) = read_file_config(&config_dir)
336 && let Some(kimi) = file.kimi
337 && let Some(ua) = kimi.user_agent
338 {
339 return ua;
340 }
341 default.to_string()
342}
343
344pub fn codex_base_url(default: &str) -> String {
349 let env: HashMap<_, _> = std::env::vars().collect();
350 if let Some(raw) = env.get("CCP_CODEX_BASE_URL") {
351 return raw.clone();
352 }
353 if let Some(raw) = env.get("CLAUDE_CODE_PROXY_CODEX_BASE_URL") {
354 return raw.clone();
355 }
356 let config_dir = paths::config_dir();
357 if let Some(file) = read_file_config(&config_dir)
358 && let Some(codex) = file.codex
359 && let Some(url) = codex.base_url
360 {
361 return url;
362 }
363 default.to_string()
364}
365
366pub fn codex_originator(default: &str) -> String {
367 let env: HashMap<_, _> = std::env::vars().collect();
368 if let Some(raw) = env.get("CCP_CODEX_ORIGINATOR") {
369 return raw.clone();
370 }
371 let config_dir = paths::config_dir();
372 if let Some(file) = read_file_config(&config_dir)
373 && let Some(codex) = file.codex
374 && let Some(val) = codex.originator
375 {
376 return val;
377 }
378 default.to_string()
379}
380
381pub fn codex_user_agent(default: &str) -> String {
382 let env: HashMap<_, _> = std::env::vars().collect();
383 if let Some(raw) = env.get("CCP_CODEX_USER_AGENT") {
384 return raw.clone();
385 }
386 if let Some(raw) = env.get("CCP_USER_AGENT") {
387 return raw.clone();
388 }
389 let config_dir = paths::config_dir();
390 if let Some(file) = read_file_config(&config_dir)
391 && let Some(codex) = file.codex
392 && let Some(ua) = codex.user_agent
393 {
394 return ua;
395 }
396 default.to_string()
397}
398
399pub fn codex_previous_response_id() -> bool {
400 let env: HashMap<_, _> = std::env::vars().collect();
401 if let Some(raw) = env.get("CCP_CODEX_PREVIOUS_RESPONSE_ID") {
402 return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
403 }
404 let config_dir = paths::config_dir();
405 if let Some(file) = read_file_config(&config_dir)
406 && let Some(codex) = file.codex
407 && let Some(val) = codex.previous_response_id
408 {
409 return val;
410 }
411 false
412}
413
414pub fn codex_service_tier() -> Option<String> {
415 let env: HashMap<_, _> = std::env::vars().collect();
416 if let Some(raw) = env.get("CCP_CODEX_SERVICE_TIER") {
417 return Some(raw.clone());
418 }
419 let config_dir = paths::config_dir();
420 if let Some(file) = read_file_config(&config_dir)
421 && let Some(codex) = file.codex
422 {
423 return codex.service_tier;
424 }
425 None
426}
427
428pub fn codex_effort() -> Option<String> {
429 let env: HashMap<_, _> = std::env::vars().collect();
430 if let Some(raw) = env.get("CCP_CODEX_EFFORT") {
431 return Some(raw.clone());
432 }
433 let config_dir = paths::config_dir();
434 if let Some(file) = read_file_config(&config_dir)
435 && let Some(codex) = file.codex
436 {
437 return codex.effort;
438 }
439 None
440}
441
442pub fn codex_reasoning_summary() -> Option<String> {
443 let env: HashMap<_, _> = std::env::vars().collect();
444 if let Some(raw) = env
445 .get("CCP_CODEX_REASONING_SUMMARY")
446 .filter(|raw| !raw.is_empty())
447 {
448 return Some(raw.clone());
449 }
450 let config_dir = paths::config_dir();
451 if let Some(file) = read_file_config(&config_dir)
452 && let Some(codex) = file.codex
453 && let Some(summary) = codex.reasoning_summary.filter(|raw| !raw.is_empty())
454 {
455 return Some(summary);
456 }
457 None
458}
459
460pub fn codex_model() -> Option<String> {
461 let env: HashMap<_, _> = std::env::vars().collect();
462 if let Some(raw) = env.get("CCP_CODEX_MODEL") {
463 return Some(raw.clone());
464 }
465 let config_dir = paths::config_dir();
466 if let Some(file) = read_file_config(&config_dir)
467 && let Some(codex) = file.codex
468 {
469 return codex.model;
470 }
471 None
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub enum CodexTransport {
480 Http,
481 WebSocket,
482 Auto,
483}
484
485impl CodexTransport {
486 pub fn as_str(self) -> &'static str {
487 match self {
488 CodexTransport::Http => "http",
489 CodexTransport::WebSocket => "websocket",
490 CodexTransport::Auto => "auto",
491 }
492 }
493}
494
495fn parse_codex_transport(raw: &str) -> Option<CodexTransport> {
496 match raw {
497 "http" => Some(CodexTransport::Http),
498 "websocket" => Some(CodexTransport::WebSocket),
499 "auto" => Some(CodexTransport::Auto),
500 _ => None,
501 }
502}
503
504pub fn codex_transport() -> CodexTransport {
505 let env: HashMap<_, _> = std::env::vars().collect();
506 if let Some(raw) = env.get("CCP_CODEX_TRANSPORT")
507 && let Some(transport) = parse_codex_transport(raw)
508 {
509 return transport;
510 }
511 let config_dir = paths::config_dir();
512 if let Some(file) = read_file_config(&config_dir)
513 && let Some(codex) = file.codex
514 && let Some(transport) = codex.transport.as_deref().and_then(parse_codex_transport)
515 {
516 return transport;
517 }
518 CodexTransport::WebSocket
519}
520
521pub fn cursor_base_url() -> String {
526 let env: HashMap<_, _> = std::env::vars().collect();
527 if let Some(raw) = env.get("CCP_CURSOR_BASE_URL") {
528 return raw.clone();
529 }
530 let config_dir = paths::config_dir();
531 if let Some(file) = read_file_config(&config_dir)
532 && let Some(cursor) = file.cursor
533 && let Some(url) = cursor.base_url
534 {
535 return url;
536 }
537 "https://api2.cursor.sh".to_string()
538}
539
540pub fn cursor_client_version() -> String {
541 let env: HashMap<_, _> = std::env::vars().collect();
542 if let Some(raw) = env.get("CCP_CURSOR_CLIENT_VERSION") {
543 return raw.clone();
544 }
545 let config_dir = paths::config_dir();
546 if let Some(file) = read_file_config(&config_dir)
547 && let Some(cursor) = file.cursor
548 && let Some(version) = cursor.client_version
549 {
550 return version;
551 }
552 "0.48.5".to_string()
553}
554
555pub fn cursor_agent_bundle() -> Option<String> {
556 let env: HashMap<_, _> = std::env::vars().collect();
557 if let Some(raw) = env.get("CCP_CURSOR_AGENT_BUNDLE") {
558 return Some(raw.clone());
559 }
560 let config_dir = paths::config_dir();
561 if let Some(file) = read_file_config(&config_dir)
562 && let Some(cursor) = file.cursor
563 && let Some(bundle) = cursor.agent_bundle
564 {
565 return Some(bundle);
566 }
567 None
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use once_cell::sync::Lazy;
574 use std::sync::Mutex;
575
576 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
577
578 fn clear_env() {
579 unsafe {
580 std::env::remove_var("CCP_CODEX_TRANSPORT");
581 std::env::remove_var("CCP_CONFIG_DIR");
582 std::env::remove_var("CCP_LOG_VERBOSE");
583 std::env::remove_var("CCP_LOG_STDERR");
584 std::env::remove_var("CCP_CODEX_REASONING_SUMMARY");
585 }
586 }
587
588 struct EnvGuard {
589 key: &'static str,
590 previous: Option<std::ffi::OsString>,
591 }
592
593 impl EnvGuard {
594 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
595 let previous = std::env::var_os(key);
596 unsafe {
597 std::env::set_var(key, value);
598 }
599 Self { key, previous }
600 }
601 }
602
603 impl Drop for EnvGuard {
604 fn drop(&mut self) {
605 unsafe {
606 match self.previous.take() {
607 Some(value) => std::env::set_var(self.key, value),
608 None => std::env::remove_var(self.key),
609 }
610 }
611 }
612 }
613
614 #[test]
615 fn codex_transport_defaults_to_websocket() {
616 let _guard = ENV_LOCK.lock().unwrap();
617 clear_env();
618 let result = codex_transport();
619 assert_eq!(result, CodexTransport::WebSocket);
620 }
621
622 #[test]
623 fn codex_transport_reads_env() {
624 let _guard = ENV_LOCK.lock().unwrap();
625 clear_env();
626 unsafe {
627 std::env::set_var("CCP_CODEX_TRANSPORT", "auto");
628 }
629 assert_eq!(codex_transport(), CodexTransport::Auto);
630 }
631
632 #[test]
633 fn codex_transport_env_websocket() {
634 let _guard = ENV_LOCK.lock().unwrap();
635 clear_env();
636 unsafe {
637 std::env::set_var("CCP_CODEX_TRANSPORT", "websocket");
638 }
639 assert_eq!(codex_transport(), CodexTransport::WebSocket);
640 }
641
642 #[test]
643 fn codex_transport_invalid_env_falls_back_to_websocket() {
644 let _guard = ENV_LOCK.lock().unwrap();
645 clear_env();
646 unsafe {
647 std::env::set_var("CCP_CODEX_TRANSPORT", "invalid");
648 }
649 assert_eq!(codex_transport(), CodexTransport::WebSocket);
650 }
651
652 #[test]
653 fn codex_transport_empty_env_falls_back_to_websocket() {
654 let _guard = ENV_LOCK.lock().unwrap();
655 clear_env();
656 unsafe {
657 std::env::set_var("CCP_CODEX_TRANSPORT", "");
658 }
659 assert_eq!(codex_transport(), CodexTransport::WebSocket);
660 }
661
662 #[test]
663 fn parse_codex_transport_variants() {
664 assert_eq!(parse_codex_transport("http"), Some(CodexTransport::Http));
665 assert_eq!(
666 parse_codex_transport("websocket"),
667 Some(CodexTransport::WebSocket)
668 );
669 assert_eq!(parse_codex_transport("auto"), Some(CodexTransport::Auto));
670 assert_eq!(parse_codex_transport(""), None);
671 assert_eq!(parse_codex_transport("HTTP"), None);
672 assert_eq!(parse_codex_transport("ws"), None);
673 }
674
675 #[test]
676 fn codex_transport_as_str() {
677 assert_eq!(CodexTransport::Http.as_str(), "http");
678 assert_eq!(CodexTransport::WebSocket.as_str(), "websocket");
679 assert_eq!(CodexTransport::Auto.as_str(), "auto");
680 }
681
682 #[test]
683 fn log_env_presence_enables_legacy_verbose_and_stderr() {
684 let _guard = ENV_LOCK.lock().unwrap();
685 clear_env();
686 let config = tempfile::TempDir::new().unwrap();
687 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
688 let _verbose_env = EnvGuard::set("CCP_LOG_VERBOSE", "0");
689 let _stderr_env = EnvGuard::set("CCP_LOG_STDERR", "");
690
691 let loaded = load_config();
692 assert!(loaded.log_verbose);
693 assert!(loaded.log_stderr);
694 }
695
696 #[test]
697 fn log_config_values_apply_without_env() {
698 let _guard = ENV_LOCK.lock().unwrap();
699 clear_env();
700 let config = tempfile::TempDir::new().unwrap();
701 std::fs::write(
702 config.path().join("config.json"),
703 r#"{"log":{"verbose":true,"stderr":true}}"#,
704 )
705 .unwrap();
706 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
707
708 let loaded = load_config();
709 assert!(loaded.log_verbose);
710 assert!(loaded.log_stderr);
711 }
712
713 #[test]
714 fn codex_reasoning_summary_reads_config() {
715 let _guard = ENV_LOCK.lock().unwrap();
716 clear_env();
717 let config = tempfile::TempDir::new().unwrap();
718 std::fs::write(
719 config.path().join("config.json"),
720 r#"{"codex":{"reasoningSummary":"off"}}"#,
721 )
722 .unwrap();
723 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
724
725 assert_eq!(codex_reasoning_summary().as_deref(), Some("off"));
726 }
727
728 #[test]
729 fn codex_reasoning_summary_env_overrides_config_and_empty_falls_through() {
730 let _guard = ENV_LOCK.lock().unwrap();
731 clear_env();
732 let config = tempfile::TempDir::new().unwrap();
733 std::fs::write(
734 config.path().join("config.json"),
735 r#"{"codex":{"reasoningSummary":"off"}}"#,
736 )
737 .unwrap();
738 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
739 {
740 let _summary_env = EnvGuard::set("CCP_CODEX_REASONING_SUMMARY", "auto");
741 assert_eq!(codex_reasoning_summary().as_deref(), Some("auto"));
742 }
743 {
744 let _summary_env = EnvGuard::set("CCP_CODEX_REASONING_SUMMARY", "");
745 assert_eq!(codex_reasoning_summary().as_deref(), Some("off"));
746 }
747 }
748}