Skip to main content

codex_cli/starship/
mod.rs

1use std::path::Path;
2
3use crate::auth;
4use crate::paths;
5use crate::rate_limits::cache;
6use nils_common::env as shared_env;
7
8mod lock;
9mod refresh;
10mod render;
11
12pub use render::CacheEntry;
13
14pub struct StarshipOptions {
15    pub no_5h: bool,
16    pub ttl: Option<String>,
17    pub time_format: Option<String>,
18    pub show_timezone: bool,
19    pub refresh: bool,
20    pub is_enabled: bool,
21}
22
23const DEFAULT_TTL_SECONDS: u64 = 300;
24const DEFAULT_TIME_FORMAT: &str = "%m-%d %H:%M";
25const DEFAULT_TIME_FORMAT_WITH_TIMEZONE: &str = "%m-%d %H:%M %:z";
26
27pub fn run(options: &StarshipOptions) -> i32 {
28    if options.is_enabled {
29        return if starship_enabled() { 0 } else { 1 };
30    }
31
32    let ttl_seconds = match resolve_ttl_seconds(options.ttl.as_deref()) {
33        Ok(value) => value,
34        Err(_) => {
35            print_ttl_usage();
36            return 2;
37        }
38    };
39
40    if !starship_enabled() {
41        return 0;
42    }
43
44    let target_file = match paths::resolve_auth_file() {
45        Some(path) => path,
46        None => return 0,
47    };
48
49    let show_5h =
50        shared_env::env_truthy_or("CODEX_STARSHIP_SHOW_5H_ENABLED", true) && !options.no_5h;
51    let time_format = match options.time_format.as_deref() {
52        Some(value) => value,
53        None if options.show_timezone => DEFAULT_TIME_FORMAT_WITH_TIMEZONE,
54        None => DEFAULT_TIME_FORMAT,
55    };
56    let stale_suffix =
57        std::env::var("CODEX_STARSHIP_STALE_SUFFIX").unwrap_or_else(|_| " (stale)".to_string());
58
59    let prefix = resolve_name_prefix(&target_file);
60
61    if options.refresh {
62        if let Some(entry) = refresh::refresh_blocking(&target_file)
63            && let Some(line) = render::render_line(&entry, &prefix, show_5h, time_format)
64            && !line.trim().is_empty()
65        {
66            println!("{line}");
67        }
68        return 0;
69    }
70
71    let (cached, is_stale) = read_cached_entry(&target_file, ttl_seconds);
72    if let Some(entry) = cached.clone()
73        && let Some(mut line) = render::render_line(&entry, &prefix, show_5h, time_format)
74    {
75        if is_stale {
76            line.push_str(&stale_suffix);
77        }
78        if !line.trim().is_empty() {
79            println!("{line}");
80        }
81    }
82
83    if cached.is_none() || is_stale {
84        refresh::enqueue_background_refresh(&target_file);
85    }
86
87    0
88}
89
90fn starship_enabled() -> bool {
91    shared_env::env_truthy("CODEX_STARSHIP_ENABLED")
92}
93
94fn resolve_ttl_seconds(cli_ttl: Option<&str>) -> Result<u64, ()> {
95    if let Some(raw) = cli_ttl {
96        return parse_duration_seconds(raw).ok_or(());
97    }
98
99    if let Ok(raw) = std::env::var("CODEX_STARSHIP_TTL")
100        && let Some(value) = parse_duration_seconds(&raw)
101    {
102        return Ok(value);
103    }
104
105    Ok(DEFAULT_TTL_SECONDS)
106}
107
108fn parse_duration_seconds(raw: &str) -> Option<u64> {
109    let raw = raw.trim();
110    if raw.is_empty() {
111        return None;
112    }
113
114    let raw = raw.to_ascii_lowercase();
115    let (num_part, multiplier): (&str, u64) = match raw.chars().last()? {
116        's' => (&raw[..raw.len().saturating_sub(1)], 1),
117        'm' => (&raw[..raw.len().saturating_sub(1)], 60),
118        'h' => (&raw[..raw.len().saturating_sub(1)], 60 * 60),
119        'd' => (&raw[..raw.len().saturating_sub(1)], 60 * 60 * 24),
120        'w' => (&raw[..raw.len().saturating_sub(1)], 60 * 60 * 24 * 7),
121        ch if ch.is_ascii_digit() => (raw.as_str(), 1),
122        _ => return None,
123    };
124
125    let num_part = num_part.trim();
126    if num_part.is_empty() {
127        return None;
128    }
129
130    let value = num_part.parse::<u64>().ok()?;
131    if value == 0 {
132        return None;
133    }
134
135    value.checked_mul(multiplier)
136}
137
138fn print_ttl_usage() {
139    eprintln!("codex-cli starship: invalid --ttl");
140    eprintln!(
141        "usage: codex-cli starship [--no-5h] [--ttl <duration>] [--time-format <strftime>] [--show-timezone] [--refresh] [--is-enabled]"
142    );
143}
144
145fn read_cached_entry(target_file: &Path, ttl_seconds: u64) -> (Option<CacheEntry>, bool) {
146    let cache_file = match cache::cache_file_for_target(target_file) {
147        Ok(value) => value,
148        Err(_) => return (None, false),
149    };
150    if !cache_file.is_file() {
151        return (None, false);
152    }
153
154    let entry = render::read_cache_file(&cache_file);
155    let Some(entry) = entry else {
156        return (None, false);
157    };
158
159    let now_epoch = chrono::Utc::now().timestamp();
160    if now_epoch <= 0 || entry.fetched_at_epoch <= 0 {
161        return (Some(entry), true);
162    }
163
164    let ttl_i64 = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
165    let stale = now_epoch.saturating_sub(entry.fetched_at_epoch) > ttl_i64;
166    (Some(entry), stale)
167}
168
169fn resolve_name_prefix(target_file: &Path) -> String {
170    let name = resolve_name(target_file);
171    match name {
172        Some(value) if !value.trim().is_empty() => format!("{} ", value.trim()),
173        _ => String::new(),
174    }
175}
176
177fn resolve_name(target_file: &Path) -> Option<String> {
178    let name_source = std::env::var("CODEX_STARSHIP_NAME_SOURCE")
179        .ok()
180        .map(|value| value.to_ascii_lowercase())
181        .unwrap_or_else(|| "secret".to_string());
182
183    let show_fallback = shared_env::env_truthy("CODEX_STARSHIP_SHOW_FALLBACK_NAME_ENABLED");
184    let show_full_email = shared_env::env_truthy("CODEX_STARSHIP_SHOW_FULL_EMAIL_ENABLED");
185
186    if name_source == "email" {
187        if let Ok(Some(email)) = auth::email_from_auth_file(target_file) {
188            return Some(format_email_name(&email, show_full_email));
189        }
190        if show_fallback && let Ok(Some(identity)) = auth::identity_from_auth_file(target_file) {
191            return Some(format_email_name(&identity, show_full_email));
192        }
193        return None;
194    }
195
196    if let Some(secret_name) = cache::secret_name_for_target(target_file) {
197        return Some(secret_name);
198    }
199
200    if show_fallback && let Ok(Some(identity)) = auth::identity_from_auth_file(target_file) {
201        return Some(format_email_name(&identity, show_full_email));
202    }
203
204    None
205}
206
207fn format_email_name(raw: &str, show_full_email: bool) -> String {
208    let trimmed = raw.trim();
209    if show_full_email {
210        return trimmed.to_string();
211    }
212    trimmed.split('@').next().unwrap_or(trimmed).to_string()
213}