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 bind_address: String,
28 pub port: u16,
29 pub alias_provider: AliasProvider,
30 pub log_verbose: bool,
31 pub log_stderr: bool,
32 pub config_dir: PathBuf,
33}
34
35#[derive(Deserialize)]
36struct FileConfig {
37 #[serde(rename = "bindAddress")]
38 pub bind_address: Option<String>,
39 pub port: Option<u16>,
40 #[serde(rename = "aliasProvider")]
41 pub alias_provider: Option<String>,
42 #[serde(rename = "autoReviewModel")]
43 pub auto_review_model: Option<String>,
44 pub log: Option<FileLog>,
45 pub kimi: Option<KimiConfig>,
46 pub codex: Option<CodexConfig>,
47 pub cursor: Option<CursorConfig>,
48 pub grok: Option<GrokConfig>,
49}
50
51#[derive(Deserialize, Clone)]
52struct CodexConfig {
53 #[serde(rename = "baseUrl")]
54 pub base_url: Option<String>,
55 #[serde(rename = "originator")]
56 pub originator: Option<String>,
57 #[serde(rename = "userAgent")]
58 pub user_agent: Option<String>,
59 #[serde(rename = "previousResponseId")]
60 pub previous_response_id: Option<bool>,
61 #[serde(rename = "serverCompaction")]
62 pub server_compaction: Option<bool>,
63 #[serde(rename = "responsesApi")]
64 pub responses_api: Option<bool>,
65 #[serde(rename = "imagesApi")]
66 pub images_api: Option<bool>,
67 #[serde(rename = "imagesBaseUrl")]
68 pub images_base_url: Option<String>,
69 #[serde(rename = "transcriptionsApi")]
70 pub transcriptions_api: Option<bool>,
71 #[serde(rename = "serviceTier")]
72 pub service_tier: Option<String>,
73 #[serde(rename = "reasoningSummary")]
74 pub reasoning_summary: Option<String>,
75 #[serde(rename = "effort")]
76 pub effort: Option<String>,
77 #[serde(rename = "model")]
78 pub model: Option<String>,
79 pub transport: Option<String>,
80}
81
82#[derive(Deserialize, Clone)]
83struct CursorConfig {
84 #[serde(rename = "baseUrl")]
85 pub base_url: Option<String>,
86 #[serde(rename = "clientVersion")]
87 pub client_version: Option<String>,
88 #[serde(rename = "agentBundle")]
89 pub agent_bundle: Option<String>,
90}
91
92#[derive(Deserialize, Clone)]
93struct KimiConfig {
94 #[serde(rename = "userAgent")]
95 pub user_agent: Option<String>,
96 #[serde(rename = "oauthHost")]
97 pub oauth_host: Option<String>,
98 #[serde(rename = "baseUrl")]
99 pub base_url: Option<String>,
100}
101
102#[derive(Deserialize, Clone)]
103struct GrokConfig {
104 #[serde(rename = "baseUrl")]
105 pub base_url: Option<String>,
106 #[serde(rename = "clientVersion")]
107 pub client_version: Option<String>,
108}
109
110#[derive(Deserialize)]
111struct FileLog {
112 pub verbose: Option<bool>,
113 pub stderr: Option<bool>,
114}
115
116fn parse_alias(raw: &str) -> Option<AliasProvider> {
117 match raw {
118 "anthropic" => Some(AliasProvider::Anthropic),
119 "codex" => Some(AliasProvider::Codex),
120 "kimi" => Some(AliasProvider::Kimi),
121 _ => None,
122 }
123}
124
125fn read_file_config(config_dir: &Path) -> Option<FileConfig> {
126 let path = config_dir.join("config.json");
127 let raw = fs::read_to_string(path).ok()?;
128 serde_json::from_str(&raw).ok()
129}
130
131pub fn load_config() -> LoadedConfig {
132 let env = paths::DirResolverEnv::default();
133 let config_dir = paths::resolve_config_dir(&env);
134 load_config_from_env(&env.env, config_dir)
135}
136
137pub fn load_config_for_env(env: &HashMap<String, String>) -> LoadedConfig {
138 let home = env
139 .get("HOME")
140 .or_else(|| env.get("USERPROFILE"))
141 .cloned()
142 .unwrap_or_else(|| "/".to_string());
143 let resolver_env = paths::DirResolverEnv {
144 platform: std::env::consts::OS.to_string(),
145 env: env.clone(),
146 home,
147 };
148 let config_dir = paths::resolve_config_dir(&resolver_env);
149 load_config_from_env(env, config_dir)
150}
151
152fn load_config_from_env(env: &HashMap<String, String>, config_dir: PathBuf) -> LoadedConfig {
153 let file = read_file_config(&config_dir);
154
155 let mut out = LoadedConfig {
156 bind_address: "127.0.0.1".to_string(),
157 port: 18765,
158 alias_provider: AliasProvider::Anthropic,
159 log_verbose: false,
160 log_stderr: false,
161 config_dir: config_dir.clone(),
162 };
163
164 if let Some(raw) = env.get("CCP_BIND_ADDRESS") {
165 out.bind_address = raw.clone();
166 } else if let Some(bind_address) = file.as_ref().and_then(|f| f.bind_address.clone()) {
167 out.bind_address = bind_address;
168 }
169
170 if let Some(raw) = env.get("CCP_ALIAS_PROVIDER") {
171 if let Some(alias) = parse_alias(raw) {
172 out.alias_provider = alias;
173 }
174 } else if let Some(alias_provider) = file
175 .as_ref()
176 .and_then(|f| f.alias_provider.as_deref())
177 .and_then(parse_alias)
178 {
179 out.alias_provider = alias_provider;
180 }
181
182 if let Some(raw) = env.get("PORT") {
183 if let Ok(port) = raw.parse::<u16>() {
184 out.port = port;
185 }
186 } else if let Some(port) = file.as_ref().and_then(|f| f.port) {
187 out.port = port;
188 }
189
190 if env.contains_key("CCP_LOG_VERBOSE") {
191 out.log_verbose = true;
192 } else if let Some(value) = file
193 .as_ref()
194 .and_then(|f| f.log.as_ref().and_then(|v| v.verbose))
195 {
196 out.log_verbose = value;
197 }
198
199 if env.contains_key("CCP_LOG_STDERR") {
200 out.log_stderr = true;
201 } else if let Some(value) = file
202 .as_ref()
203 .and_then(|f| f.log.as_ref().and_then(|v| v.stderr))
204 {
205 out.log_stderr = value;
206 }
207
208 out
209}
210
211pub fn config_path() -> PathBuf {
212 paths::config_dir().join("config.json")
213}
214
215pub fn port() -> u16 {
216 load_config().port
217}
218
219pub fn bind_address() -> String {
220 load_config().bind_address
221}
222
223pub fn alias_provider() -> AliasProvider {
224 load_config().alias_provider
225}
226
227pub fn log_verbose() -> bool {
228 load_config().log_verbose
229}
230
231pub fn log_stderr() -> bool {
232 load_config().log_stderr
233}
234
235pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
236 let file = read_file_config(&cfg.config_dir);
237 let env: HashMap<_, _> = std::env::vars().collect();
238 let mut out = Vec::new();
239 if env.contains_key("CCP_BIND_ADDRESS") {
240 out.push("bindAddress (env)".to_string());
241 }
242 if env.contains_key("PORT") {
243 out.push("port (env)".to_string());
244 }
245 if env.contains_key("CCP_ALIAS_PROVIDER") {
246 out.push("aliasProvider (env)".to_string());
247 }
248 if env.contains_key("CCP_LOG_VERBOSE") {
249 out.push("log.verbose (env)".to_string());
250 }
251 if env.contains_key("CCP_LOG_STDERR") {
252 out.push("log.stderr (env)".to_string());
253 }
254 if env.contains_key("CCP_CODEX_RESPONSES_API") {
255 out.push("codex.responsesApi (env)".to_string());
256 }
257 if env.contains_key("CCP_CODEX_IMAGES_API") {
258 out.push("codex.imagesApi (env)".to_string());
259 }
260 if env.contains_key("CCP_CODEX_IMAGES_BASE_URL") {
261 out.push("codex.imagesBaseUrl (env)".to_string());
262 }
263 if env.contains_key("CCP_CODEX_TRANSCRIPTIONS_API") {
264 out.push("codex.transcriptionsApi (env)".to_string());
265 }
266 if env.contains_key("CCP_KIMI_OAUTH_HOST") {
267 out.push("kimi.oauthHost (env)".to_string());
268 }
269 if env.contains_key("CCP_KIMI_BASE_URL") {
270 out.push("kimi.baseUrl (env)".to_string());
271 }
272 if env.contains_key("CCP_CURSOR_BASE_URL") {
273 out.push("cursor.baseUrl (env)".to_string());
274 }
275 if env.contains_key("CCP_CURSOR_CLIENT_VERSION") {
276 out.push("cursor.clientVersion (env)".to_string());
277 }
278 if env.contains_key("CCP_KIMI_USER_AGENT") {
279 out.push("kimi.userAgent (env)".to_string());
280 }
281 if env.contains_key("CCP_GROK_BASE_URL") {
282 out.push("grok.baseUrl (env)".to_string());
283 }
284 if env.contains_key("CCP_GROK_CLIENT_VERSION") {
285 out.push("grok.clientVersion (env)".to_string());
286 }
287 if env
288 .get("CCP_CODEX_REASONING_SUMMARY")
289 .is_some_and(|raw| !raw.is_empty())
290 {
291 out.push("CCP_CODEX_REASONING_SUMMARY (env)".to_string());
292 }
293 if env.contains_key("CCP_CODEX_SERVER_COMPACTION") {
294 out.push("CCP_CODEX_SERVER_COMPACTION (env)".to_string());
295 }
296 if env
297 .get("CCP_AUTO_REVIEW_MODEL")
298 .is_some_and(|raw| !raw.is_empty())
299 {
300 out.push("CCP_AUTO_REVIEW_MODEL (env)".to_string());
301 }
302 if let Some(file_cfg) = file {
303 if let Some(bind_address) = file_cfg.bind_address {
304 out.push(format!("bindAddress: {bind_address}"));
305 }
306 if let Some(p) = file_cfg.port {
307 out.push(format!("port: {p}"));
308 }
309 if let Some(alias) = file_cfg.alias_provider {
310 out.push(format!("aliasProvider: {alias}"));
311 }
312 if file_cfg
313 .auto_review_model
314 .is_some_and(|model| !model.is_empty())
315 {
316 out.push("autoReviewModel (config)".to_string());
317 }
318 if let Some(log) = file_cfg.log {
319 if let Some(v) = log.verbose {
320 out.push(format!("log.verbose: {v}"));
321 }
322 if let Some(v) = log.stderr {
323 out.push(format!("log.stderr: {v}"));
324 }
325 }
326 if let Some(codex) = file_cfg.codex {
327 if codex
328 .reasoning_summary
329 .is_some_and(|value| !value.is_empty())
330 {
331 out.push("codex.reasoningSummary (config)".to_string());
332 }
333 if let Some(enabled) = codex.server_compaction {
334 out.push(format!("codex.serverCompaction: {enabled}"));
335 }
336 if codex.responses_api == Some(true) {
337 out.push("codex.responsesApi: true".to_string());
338 }
339 if codex.images_api == Some(true) {
340 out.push("codex.imagesApi: true".to_string());
341 }
342 if codex.images_base_url.is_some() {
343 out.push("codex.imagesBaseUrl (config)".to_string());
344 }
345 if codex.transcriptions_api == Some(true) {
346 out.push("codex.transcriptionsApi: true".to_string());
347 }
348 }
349 }
350 out
351}
352
353pub fn grok_base_url() -> String {
354 let env: HashMap<_, _> = std::env::vars().collect();
355 if let Some(raw) = env.get("CCP_GROK_BASE_URL") {
356 return raw.clone();
357 }
358 if let Some(grok) = read_file_config(&paths::config_dir()).and_then(|f| f.grok)
359 && let Some(url) = grok.base_url
360 {
361 return url;
362 }
363 "https://cli-chat-proxy.grok.com/v1".to_string()
364}
365
366pub fn grok_client_version() -> String {
367 let env: HashMap<_, _> = std::env::vars().collect();
368 if let Some(raw) = env.get("CCP_GROK_CLIENT_VERSION") {
369 return raw.clone();
370 }
371 if let Some(grok) = read_file_config(&paths::config_dir()).and_then(|f| f.grok)
372 && let Some(version) = grok.client_version
373 {
374 return version;
375 }
376 "0.2.93".to_string()
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum GrokToolImageMode {
392 Omit,
393 Reattach,
394 Inline,
395 Reject,
396}
397
398pub fn parse_grok_tool_image_mode(raw: Option<&str>) -> GrokToolImageMode {
399 match raw.map(str::trim) {
400 Some("reattach") => GrokToolImageMode::Reattach,
401 Some("inline") => GrokToolImageMode::Inline,
402 Some("reject") => GrokToolImageMode::Reject,
403 _ => GrokToolImageMode::Omit,
405 }
406}
407
408pub fn grok_tool_image_mode() -> GrokToolImageMode {
409 parse_grok_tool_image_mode(std::env::var("CCP_GROK_TOOL_IMAGE").ok().as_deref())
410}
411
412pub fn warn_grok_tool_image_mode_once(log: &crate::logging::Logger) {
415 match std::env::var("CCP_GROK_TOOL_IMAGE")
416 .ok()
417 .as_deref()
418 .map(str::trim)
419 {
420 Some(other) if !matches!(other, "" | "omit" | "reattach" | "inline" | "reject") => {
421 let mut fields = serde_json::Map::new();
422 fields.insert(
423 "value".to_string(),
424 serde_json::Value::String(other.to_string()),
425 );
426 log.warn(
427 "unrecognized CCP_GROK_TOOL_IMAGE value; falling back to omit",
428 Some(fields),
429 );
430 }
431 _ => {}
432 }
433}
434
435pub fn is_verbose() -> bool {
436 log_verbose()
437}
438
439pub fn anthropic_base_url() -> String {
440 let env: HashMap<_, _> = std::env::vars().collect();
441 if let Some(raw) = env.get("CCP_ANTHROPIC_BASE_URL") {
442 return raw.clone();
443 }
444 "https://api.anthropic.com".to_string()
445}
446
447pub fn kimi_oauth_host() -> String {
448 let env: HashMap<_, _> = std::env::vars().collect();
449 if let Some(raw) = env.get("CCP_KIMI_OAUTH_HOST") {
450 return raw.clone();
451 }
452 let config_dir = paths::config_dir();
453 if let Some(file) = read_file_config(&config_dir)
454 && let Some(kimi) = file.kimi
455 && let Some(host) = kimi.oauth_host
456 {
457 return host;
458 }
459 "https://auth.kimi.com".to_string()
460}
461
462pub fn kimi_base_url() -> String {
463 let env: HashMap<_, _> = std::env::vars().collect();
464 if let Some(raw) = env.get("CCP_KIMI_BASE_URL") {
465 return raw.clone();
466 }
467 let config_dir = paths::config_dir();
468 if let Some(file) = read_file_config(&config_dir)
469 && let Some(kimi) = file.kimi
470 && let Some(url) = kimi.base_url
471 {
472 return url;
473 }
474 "https://api.kimi.com/coding/v1".to_string()
475}
476
477pub fn kimi_user_agent(default: &str) -> String {
478 let env: HashMap<_, _> = std::env::vars().collect();
479 if let Some(raw) = env.get("CCP_KIMI_USER_AGENT") {
480 return raw.clone();
481 }
482 if let Some(raw) = env.get("CCP_USER_AGENT") {
483 return raw.clone();
484 }
485 let config_dir = paths::config_dir();
486 if let Some(file) = read_file_config(&config_dir)
487 && let Some(kimi) = file.kimi
488 && let Some(ua) = kimi.user_agent
489 {
490 return ua;
491 }
492 default.to_string()
493}
494
495pub fn codex_base_url(default: &str) -> String {
500 let env: HashMap<_, _> = std::env::vars().collect();
501 if let Some(raw) = env.get("CCP_CODEX_BASE_URL") {
502 return raw.clone();
503 }
504 if let Some(raw) = env.get("CLAUDE_CODE_PROXY_CODEX_BASE_URL") {
505 return raw.clone();
506 }
507 let config_dir = paths::config_dir();
508 if let Some(file) = read_file_config(&config_dir)
509 && let Some(codex) = file.codex
510 && let Some(url) = codex.base_url
511 {
512 return url;
513 }
514 default.to_string()
515}
516
517pub fn codex_originator(default: &str) -> String {
518 let env: HashMap<_, _> = std::env::vars().collect();
519 if let Some(raw) = env.get("CCP_CODEX_ORIGINATOR") {
520 return raw.clone();
521 }
522 let config_dir = paths::config_dir();
523 if let Some(file) = read_file_config(&config_dir)
524 && let Some(codex) = file.codex
525 && let Some(val) = codex.originator
526 {
527 return val;
528 }
529 default.to_string()
530}
531
532pub fn codex_user_agent(default: &str) -> String {
533 let env: HashMap<_, _> = std::env::vars().collect();
534 if let Some(raw) = env.get("CCP_CODEX_USER_AGENT") {
535 return raw.clone();
536 }
537 if let Some(raw) = env.get("CCP_USER_AGENT") {
538 return raw.clone();
539 }
540 let config_dir = paths::config_dir();
541 if let Some(file) = read_file_config(&config_dir)
542 && let Some(codex) = file.codex
543 && let Some(ua) = codex.user_agent
544 {
545 return ua;
546 }
547 default.to_string()
548}
549
550pub fn codex_previous_response_id() -> bool {
551 let env: HashMap<_, _> = std::env::vars().collect();
552 if let Some(raw) = env.get("CCP_CODEX_PREVIOUS_RESPONSE_ID") {
553 return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
554 }
555 let config_dir = paths::config_dir();
556 if let Some(file) = read_file_config(&config_dir)
557 && let Some(codex) = file.codex
558 && let Some(val) = codex.previous_response_id
559 {
560 return val;
561 }
562 false
563}
564
565pub fn codex_server_compaction() -> bool {
566 let env: HashMap<_, _> = std::env::vars().collect();
567 if let Some(raw) = env.get("CCP_CODEX_SERVER_COMPACTION") {
568 match raw.trim().to_ascii_lowercase().as_str() {
569 "1" | "true" | "yes" | "on" => return true,
570 "0" | "false" | "no" | "off" => return false,
571 _ => {}
572 }
573 }
574 let config_dir = paths::config_dir();
575 if let Some(file) = read_file_config(&config_dir)
576 && let Some(codex) = file.codex
577 && let Some(enabled) = codex.server_compaction
578 {
579 return enabled;
580 }
581 false
582}
583
584pub fn codex_responses_api() -> bool {
585 let env: HashMap<_, _> = std::env::vars().collect();
586 if let Some(raw) = env.get("CCP_CODEX_RESPONSES_API") {
587 return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
588 }
589 let config_dir = paths::config_dir();
590 if let Some(file) = read_file_config(&config_dir)
591 && let Some(codex) = file.codex
592 && let Some(enabled) = codex.responses_api
593 {
594 return enabled;
595 }
596 false
597}
598
599pub fn codex_images_api() -> bool {
600 let env: HashMap<_, _> = std::env::vars().collect();
601 if let Some(raw) = env.get("CCP_CODEX_IMAGES_API") {
602 return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
603 }
604 let config_dir = paths::config_dir();
605 if let Some(file) = read_file_config(&config_dir)
606 && let Some(codex) = file.codex
607 && let Some(enabled) = codex.images_api
608 {
609 return enabled;
610 }
611 false
612}
613
614pub fn codex_transcriptions_api() -> bool {
615 let env: HashMap<_, _> = std::env::vars().collect();
616 if let Some(raw) = env.get("CCP_CODEX_TRANSCRIPTIONS_API") {
617 return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
618 }
619 let config_dir = paths::config_dir();
620 if let Some(file) = read_file_config(&config_dir)
621 && let Some(codex) = file.codex
622 && let Some(enabled) = codex.transcriptions_api
623 {
624 return enabled;
625 }
626 false
627}
628
629pub fn codex_images_base_url() -> String {
630 let env: HashMap<_, _> = std::env::vars().collect();
631 if let Some(raw) = env.get("CCP_CODEX_IMAGES_BASE_URL") {
632 return raw.clone();
633 }
634 let config_dir = paths::config_dir();
635 if let Some(file) = read_file_config(&config_dir)
636 && let Some(codex) = file.codex
637 && let Some(url) = codex.images_base_url
638 {
639 return url;
640 }
641 "https://chatgpt.com/backend-api/codex".to_string()
642}
643
644pub fn codex_service_tier() -> Option<String> {
645 let env: HashMap<_, _> = std::env::vars().collect();
646 if let Some(raw) = env.get("CCP_CODEX_SERVICE_TIER") {
647 return Some(raw.clone());
648 }
649 let config_dir = paths::config_dir();
650 if let Some(file) = read_file_config(&config_dir)
651 && let Some(codex) = file.codex
652 {
653 return codex.service_tier;
654 }
655 None
656}
657
658pub fn codex_effort() -> Option<String> {
659 let env: HashMap<_, _> = std::env::vars().collect();
660 if let Some(raw) = env.get("CCP_CODEX_EFFORT") {
661 return Some(raw.clone());
662 }
663 let config_dir = paths::config_dir();
664 if let Some(file) = read_file_config(&config_dir)
665 && let Some(codex) = file.codex
666 {
667 return codex.effort;
668 }
669 None
670}
671
672pub fn codex_reasoning_summary() -> Option<String> {
673 let env: HashMap<_, _> = std::env::vars().collect();
674 if let Some(raw) = env
675 .get("CCP_CODEX_REASONING_SUMMARY")
676 .filter(|raw| !raw.is_empty())
677 {
678 return Some(raw.clone());
679 }
680 let config_dir = paths::config_dir();
681 if let Some(file) = read_file_config(&config_dir)
682 && let Some(codex) = file.codex
683 && let Some(summary) = codex.reasoning_summary.filter(|raw| !raw.is_empty())
684 {
685 return Some(summary);
686 }
687 None
688}
689
690pub fn codex_model() -> Option<String> {
691 let env: HashMap<_, _> = std::env::vars().collect();
692 if let Some(raw) = env.get("CCP_CODEX_MODEL") {
693 return Some(raw.clone());
694 }
695 let config_dir = paths::config_dir();
696 if let Some(file) = read_file_config(&config_dir)
697 && let Some(codex) = file.codex
698 {
699 return codex.model;
700 }
701 None
702}
703
704pub fn auto_review_model() -> Option<String> {
705 let env: HashMap<_, _> = std::env::vars().collect();
706 if let Some(raw) = env
707 .get("CCP_AUTO_REVIEW_MODEL")
708 .filter(|raw| !raw.is_empty())
709 {
710 return Some(raw.clone());
711 }
712 read_file_config(&paths::config_dir())
713 .and_then(|file| file.auto_review_model)
714 .filter(|model| !model.is_empty())
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum CodexTransport {
723 Http,
724 WebSocket,
725 Auto,
726}
727
728impl CodexTransport {
729 pub fn as_str(self) -> &'static str {
730 match self {
731 CodexTransport::Http => "http",
732 CodexTransport::WebSocket => "websocket",
733 CodexTransport::Auto => "auto",
734 }
735 }
736}
737
738fn parse_codex_transport(raw: &str) -> Option<CodexTransport> {
739 match raw {
740 "http" => Some(CodexTransport::Http),
741 "websocket" => Some(CodexTransport::WebSocket),
742 "auto" => Some(CodexTransport::Auto),
743 _ => None,
744 }
745}
746
747pub fn codex_transport() -> CodexTransport {
748 let env: HashMap<_, _> = std::env::vars().collect();
749 if let Some(raw) = env.get("CCP_CODEX_TRANSPORT")
750 && let Some(transport) = parse_codex_transport(raw)
751 {
752 return transport;
753 }
754 let config_dir = paths::config_dir();
755 if let Some(file) = read_file_config(&config_dir)
756 && let Some(codex) = file.codex
757 && let Some(transport) = codex.transport.as_deref().and_then(parse_codex_transport)
758 {
759 return transport;
760 }
761 CodexTransport::WebSocket
762}
763
764pub fn cursor_base_url() -> String {
769 let env: HashMap<_, _> = std::env::vars().collect();
770 if let Some(raw) = env.get("CCP_CURSOR_BASE_URL") {
771 return raw.clone();
772 }
773 let config_dir = paths::config_dir();
774 if let Some(file) = read_file_config(&config_dir)
775 && let Some(cursor) = file.cursor
776 && let Some(url) = cursor.base_url
777 {
778 return url;
779 }
780 "https://api2.cursor.sh".to_string()
781}
782
783pub fn cursor_client_version() -> String {
784 let env: HashMap<_, _> = std::env::vars().collect();
785 if let Some(raw) = env.get("CCP_CURSOR_CLIENT_VERSION") {
786 return raw.clone();
787 }
788 let config_dir = paths::config_dir();
789 if let Some(file) = read_file_config(&config_dir)
790 && let Some(cursor) = file.cursor
791 && let Some(version) = cursor.client_version
792 {
793 return version;
794 }
795 detect_cursor_agent_version().unwrap_or_else(|| "cli-2026.07.23-e383d2b".to_string())
796}
797
798fn detect_cursor_agent_version() -> Option<String> {
799 let output = std::process::Command::new("cursor-agent")
800 .arg("--version")
801 .output()
802 .ok()?;
803 if !output.status.success() {
804 return None;
805 }
806 let version = String::from_utf8(output.stdout).ok()?;
807 let version = version.lines().next()?.trim();
808 if version.is_empty() {
809 return None;
810 }
811 Some(if version.starts_with("cli-") {
812 version.to_string()
813 } else {
814 format!("cli-{version}")
815 })
816}
817
818pub fn cursor_agent_bundle() -> Option<String> {
819 let env: HashMap<_, _> = std::env::vars().collect();
820 if let Some(raw) = env.get("CCP_CURSOR_AGENT_BUNDLE") {
821 return Some(raw.clone());
822 }
823 let config_dir = paths::config_dir();
824 if let Some(file) = read_file_config(&config_dir)
825 && let Some(cursor) = file.cursor
826 && let Some(bundle) = cursor.agent_bundle
827 {
828 return Some(bundle);
829 }
830 None
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836 use once_cell::sync::Lazy;
837 use std::sync::Mutex;
838
839 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
840
841 fn clear_env() {
842 unsafe {
843 std::env::remove_var("CCP_BIND_ADDRESS");
844 std::env::remove_var("CCP_CODEX_TRANSPORT");
845 std::env::remove_var("CCP_CONFIG_DIR");
846 std::env::remove_var("CCP_LOG_VERBOSE");
847 std::env::remove_var("CCP_LOG_STDERR");
848 std::env::remove_var("CCP_CODEX_REASONING_SUMMARY");
849 std::env::remove_var("CCP_CODEX_SERVER_COMPACTION");
850 std::env::remove_var("CCP_CODEX_RESPONSES_API");
851 std::env::remove_var("CCP_CODEX_IMAGES_API");
852 std::env::remove_var("CCP_CODEX_IMAGES_BASE_URL");
853 std::env::remove_var("CCP_CODEX_TRANSCRIPTIONS_API");
854 std::env::remove_var("CCP_AUTO_REVIEW_MODEL");
855 }
856 }
857
858 fn config_env(config: &tempfile::TempDir) -> HashMap<String, String> {
859 HashMap::from([(
860 "CCP_CONFIG_DIR".to_string(),
861 config.path().to_string_lossy().into_owned(),
862 )])
863 }
864
865 #[test]
866 fn bind_address_defaults_to_loopback() {
867 let config = tempfile::TempDir::new().unwrap();
868 let env = config_env(&config);
869
870 assert_eq!(load_config_for_env(&env).bind_address, "127.0.0.1");
871 }
872
873 #[test]
874 fn bind_address_reads_config_and_env_takes_precedence() {
875 let config = tempfile::TempDir::new().unwrap();
876 std::fs::write(
877 config.path().join("config.json"),
878 r#"{"bindAddress":"192.0.2.10"}"#,
879 )
880 .unwrap();
881 let mut env = config_env(&config);
882
883 assert_eq!(load_config_for_env(&env).bind_address, "192.0.2.10");
884 env.insert("CCP_BIND_ADDRESS".to_string(), "0.0.0.0".to_string());
885 assert_eq!(load_config_for_env(&env).bind_address, "0.0.0.0");
886 }
887
888 struct EnvGuard {
889 key: &'static str,
890 previous: Option<std::ffi::OsString>,
891 }
892
893 impl EnvGuard {
894 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
895 let previous = std::env::var_os(key);
896 unsafe {
897 std::env::set_var(key, value);
898 }
899 Self { key, previous }
900 }
901 }
902
903 impl Drop for EnvGuard {
904 fn drop(&mut self) {
905 unsafe {
906 match self.previous.take() {
907 Some(value) => std::env::set_var(self.key, value),
908 None => std::env::remove_var(self.key),
909 }
910 }
911 }
912 }
913
914 #[test]
915 fn codex_transport_defaults_to_websocket() {
916 let _guard = ENV_LOCK.lock().unwrap();
917 clear_env();
918 let result = codex_transport();
919 assert_eq!(result, CodexTransport::WebSocket);
920 }
921
922 #[test]
923 fn codex_transport_reads_env() {
924 let _guard = ENV_LOCK.lock().unwrap();
925 clear_env();
926 unsafe {
927 std::env::set_var("CCP_CODEX_TRANSPORT", "auto");
928 }
929 assert_eq!(codex_transport(), CodexTransport::Auto);
930 }
931
932 #[test]
933 fn codex_transport_env_websocket() {
934 let _guard = ENV_LOCK.lock().unwrap();
935 clear_env();
936 unsafe {
937 std::env::set_var("CCP_CODEX_TRANSPORT", "websocket");
938 }
939 assert_eq!(codex_transport(), CodexTransport::WebSocket);
940 }
941
942 #[test]
943 fn codex_transport_invalid_env_falls_back_to_websocket() {
944 let _guard = ENV_LOCK.lock().unwrap();
945 clear_env();
946 unsafe {
947 std::env::set_var("CCP_CODEX_TRANSPORT", "invalid");
948 }
949 assert_eq!(codex_transport(), CodexTransport::WebSocket);
950 }
951
952 #[test]
953 fn codex_transport_empty_env_falls_back_to_websocket() {
954 let _guard = ENV_LOCK.lock().unwrap();
955 clear_env();
956 unsafe {
957 std::env::set_var("CCP_CODEX_TRANSPORT", "");
958 }
959 assert_eq!(codex_transport(), CodexTransport::WebSocket);
960 }
961
962 #[test]
963 fn parse_codex_transport_variants() {
964 assert_eq!(parse_codex_transport("http"), Some(CodexTransport::Http));
965 assert_eq!(
966 parse_codex_transport("websocket"),
967 Some(CodexTransport::WebSocket)
968 );
969 assert_eq!(parse_codex_transport("auto"), Some(CodexTransport::Auto));
970 assert_eq!(parse_codex_transport(""), None);
971 assert_eq!(parse_codex_transport("HTTP"), None);
972 assert_eq!(parse_codex_transport("ws"), None);
973 }
974
975 #[test]
976 fn codex_transport_as_str() {
977 assert_eq!(CodexTransport::Http.as_str(), "http");
978 assert_eq!(CodexTransport::WebSocket.as_str(), "websocket");
979 assert_eq!(CodexTransport::Auto.as_str(), "auto");
980 }
981
982 #[test]
983 fn log_env_presence_enables_legacy_verbose_and_stderr() {
984 let config = tempfile::TempDir::new().unwrap();
985 let mut env = config_env(&config);
986 env.insert("CCP_LOG_VERBOSE".to_string(), "0".to_string());
987 env.insert("CCP_LOG_STDERR".to_string(), String::new());
988
989 let loaded = load_config_for_env(&env);
990 assert!(loaded.log_verbose);
991 assert!(loaded.log_stderr);
992 }
993
994 #[test]
995 fn log_config_values_apply_without_env() {
996 let config = tempfile::TempDir::new().unwrap();
997 std::fs::write(
998 config.path().join("config.json"),
999 r#"{"log":{"verbose":true,"stderr":true}}"#,
1000 )
1001 .unwrap();
1002 let env = config_env(&config);
1003
1004 let loaded = load_config_for_env(&env);
1005 assert!(loaded.log_verbose);
1006 assert!(loaded.log_stderr);
1007 }
1008
1009 #[test]
1010 fn codex_responses_api_defaults_to_disabled() {
1011 let _guard = ENV_LOCK.lock().unwrap();
1012 clear_env();
1013 let config = tempfile::TempDir::new().unwrap();
1014 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1015
1016 assert!(!codex_responses_api());
1017 }
1018
1019 #[test]
1020 fn codex_responses_api_reads_config_and_env_takes_precedence() {
1021 let _guard = ENV_LOCK.lock().unwrap();
1022 clear_env();
1023 let config = tempfile::TempDir::new().unwrap();
1024 std::fs::write(
1025 config.path().join("config.json"),
1026 r#"{"codex":{"responsesApi":true}}"#,
1027 )
1028 .unwrap();
1029 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1030
1031 assert!(codex_responses_api());
1032 let _responses_env = EnvGuard::set("CCP_CODEX_RESPONSES_API", "false");
1033 assert!(!codex_responses_api());
1034 }
1035
1036 #[test]
1037 fn codex_responses_api_accepts_enabled_env_values() {
1038 let _guard = ENV_LOCK.lock().unwrap();
1039 clear_env();
1040 let config = tempfile::TempDir::new().unwrap();
1041 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1042
1043 for value in ["1", "true", "TRUE", "yes"] {
1044 let _responses_env = EnvGuard::set("CCP_CODEX_RESPONSES_API", value);
1045 assert!(codex_responses_api(), "{value}");
1046 }
1047 }
1048
1049 #[test]
1050 fn codex_images_api_defaults_to_disabled_and_env_overrides_config() {
1051 let _guard = ENV_LOCK.lock().unwrap();
1052 clear_env();
1053 let config = tempfile::TempDir::new().unwrap();
1054 std::fs::write(
1055 config.path().join("config.json"),
1056 r#"{"codex":{"imagesApi":true,"imagesBaseUrl":"https://chatgpt.com/backend-api/codex-custom"}}"#,
1057 )
1058 .unwrap();
1059 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1060
1061 assert!(codex_images_api());
1062 assert_eq!(
1063 codex_images_base_url(),
1064 "https://chatgpt.com/backend-api/codex-custom"
1065 );
1066 let _enabled_env = EnvGuard::set("CCP_CODEX_IMAGES_API", "false");
1067 let _base_env = EnvGuard::set(
1068 "CCP_CODEX_IMAGES_BASE_URL",
1069 "https://chatgpt.com/backend-api/codex",
1070 );
1071 assert!(!codex_images_api());
1072 assert_eq!(
1073 codex_images_base_url(),
1074 "https://chatgpt.com/backend-api/codex"
1075 );
1076 }
1077
1078 #[test]
1079 fn codex_transcriptions_api_defaults_to_disabled_and_env_overrides_config() {
1080 let _guard = ENV_LOCK.lock().unwrap();
1081 clear_env();
1082 let config = tempfile::TempDir::new().unwrap();
1083 std::fs::write(
1084 config.path().join("config.json"),
1085 r#"{"codex":{"transcriptionsApi":true}}"#,
1086 )
1087 .unwrap();
1088 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1089
1090 assert!(codex_transcriptions_api());
1091 let _enabled_env = EnvGuard::set("CCP_CODEX_TRANSCRIPTIONS_API", "false");
1092 assert!(!codex_transcriptions_api());
1093 }
1094
1095 #[test]
1096 fn codex_reasoning_summary_reads_config() {
1097 let _guard = ENV_LOCK.lock().unwrap();
1098 clear_env();
1099 let config = tempfile::TempDir::new().unwrap();
1100 std::fs::write(
1101 config.path().join("config.json"),
1102 r#"{"codex":{"reasoningSummary":"off"}}"#,
1103 )
1104 .unwrap();
1105 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1106
1107 assert_eq!(codex_reasoning_summary().as_deref(), Some("off"));
1108 }
1109
1110 #[test]
1111 fn codex_reasoning_summary_env_overrides_config_and_empty_falls_through() {
1112 let _guard = ENV_LOCK.lock().unwrap();
1113 clear_env();
1114 let config = tempfile::TempDir::new().unwrap();
1115 std::fs::write(
1116 config.path().join("config.json"),
1117 r#"{"codex":{"reasoningSummary":"off"}}"#,
1118 )
1119 .unwrap();
1120 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1121 {
1122 let _summary_env = EnvGuard::set("CCP_CODEX_REASONING_SUMMARY", "auto");
1123 assert_eq!(codex_reasoning_summary().as_deref(), Some("auto"));
1124 }
1125 {
1126 let _summary_env = EnvGuard::set("CCP_CODEX_REASONING_SUMMARY", "");
1127 assert_eq!(codex_reasoning_summary().as_deref(), Some("off"));
1128 }
1129 }
1130
1131 #[test]
1132 fn auto_review_model_reads_top_level_config_and_env_takes_precedence() {
1133 let _guard = ENV_LOCK.lock().unwrap();
1134 clear_env();
1135 let config = tempfile::TempDir::new().unwrap();
1136 std::fs::write(
1137 config.path().join("config.json"),
1138 r#"{"autoReviewModel":"grok-4.5"}"#,
1139 )
1140 .unwrap();
1141 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1142
1143 assert_eq!(auto_review_model().as_deref(), Some("grok-4.5"));
1144 {
1145 let _model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "gpt-5.6-terra");
1146 assert_eq!(auto_review_model().as_deref(), Some("gpt-5.6-terra"));
1147 }
1148 {
1149 let _model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "");
1150 assert_eq!(auto_review_model().as_deref(), Some("grok-4.5"));
1151 }
1152 }
1153
1154 #[test]
1155 fn codex_server_compaction_defaults_and_overrides() {
1156 let _guard = ENV_LOCK.lock().unwrap();
1157 clear_env();
1158 let config = tempfile::TempDir::new().unwrap();
1159 let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
1160
1161 assert!(!codex_server_compaction());
1162 {
1163 let _enabled_env = EnvGuard::set("CCP_CODEX_SERVER_COMPACTION", "on");
1164 assert!(codex_server_compaction());
1165 }
1166 std::fs::write(
1167 config.path().join("config.json"),
1168 r#"{"codex":{"serverCompaction":true}}"#,
1169 )
1170 .unwrap();
1171 assert!(codex_server_compaction());
1172 let _disabled_env = EnvGuard::set("CCP_CODEX_SERVER_COMPACTION", "false");
1173 assert!(!codex_server_compaction());
1174 }
1175}