codex_cli/starship/
mod.rs1use 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}