Skip to main content

claude_codex/
config.rs

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// ---------------------------------------------------------------------------
380// Grok tool-image policy (CCP_GROK_TOOL_IMAGE)
381// ---------------------------------------------------------------------------
382
383/// How the Grok translator treats Anthropic `image` blocks (tool results and
384/// top-level user messages). `omit` is the safe default: degrade to the L1
385/// placeholder string. `reattach` keeps the placeholder in the tool output and
386/// additionally appends a user message carrying the images as `input_image`
387/// data URLs. `inline` sends the tool output itself as an array of
388/// `input_text` + `input_image` parts (string-only outputs still serialize as
389/// plain strings). `reject` restores the pre-L1 hard error.
390#[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        // Any unknown/empty value degrades to the safe default.
404        _ => 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
412/// Warn once at startup when an unknown mode was requested. Called from the
413/// Grok provider constructor rather than per request.
414pub 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
495// ---------------------------------------------------------------------------
496// Codex config
497// ---------------------------------------------------------------------------
498
499pub 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// ---------------------------------------------------------------------------
718// Codex transport config
719// ---------------------------------------------------------------------------
720
721#[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
764// ---------------------------------------------------------------------------
765// Cursor config
766// ---------------------------------------------------------------------------
767
768pub 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}