context_bar_core/
online.rs1use std::path::{Path, PathBuf};
9
10use serde_json::Value;
11
12use crate::aggregate::{iso_utc, parse_iso};
13use crate::usage_signal::AgentUsage;
14
15const STATUSLINE_TTL: f64 = 12.0 * 3600.0;
16const CACHE_TTL_OK: u64 = 5 * 60;
17const CACHE_TTL_ERR: u64 = 15;
18
19fn round1(x: f64) -> f64 {
20 (x * 10.0).round_ties_even() / 10.0
21}
22
23pub fn parse_usage_percent(v: Option<&Value>) -> Option<f64> {
25 v.and_then(|x| x.as_f64()).map(|f| round1(f.clamp(0.0, 200.0)))
26}
27
28fn num_u64(v: &Value, key: &str) -> Option<u64> {
29 v.get(key)
30 .and_then(|x| x.as_u64().or_else(|| x.as_f64().map(|f| f.max(0.0) as u64)))
31}
32
33fn statusline_path(home: &Path) -> PathBuf {
36 if let Ok(o) = std::env::var("CONTEXTBAR_CLAUDE_STATUSLINE_PATH") {
37 if !o.is_empty() {
38 return PathBuf::from(o);
39 }
40 }
41 home.join(".context-bar").join("claude-statusline.json")
42}
43
44fn load_statusline(home: &Path, now: f64) -> Option<Value> {
45 let path = statusline_path(home);
46 let bytes = std::fs::read(&path).ok()?;
47 let payload: Value = serde_json::from_slice(&bytes).ok()?;
48 let ts = parse_iso(payload.get("updated_at").and_then(|v| v.as_str())).or_else(|| {
49 std::fs::metadata(&path)
50 .ok()?
51 .modified()
52 .ok()?
53 .duration_since(std::time::UNIX_EPOCH)
54 .ok()
55 .map(|d| d.as_secs_f64())
56 });
57 let ts = ts?;
58 if now - ts > STATUSLINE_TTL {
59 return None;
60 }
61 Some(payload)
62}
63
64fn parse_claude_rate_limit_window(rate_limits: &Value, keys: &[&str]) -> (Option<f64>, Option<String>) {
67 let mut cur = rate_limits;
68 for k in keys {
69 match cur.get(k) {
70 Some(v) if v.is_object() => cur = v,
71 _ => return (None, None),
72 }
73 }
74 if !cur.is_object() {
75 return (None, None);
76 }
77 let pct = parse_usage_percent(cur.get("used_percentage"))
78 .or_else(|| parse_usage_percent(cur.get("utilization")))
79 .or_else(|| parse_usage_percent(cur.get("used_percent")));
80 let resets = match cur.get("resets_at") {
81 Some(Value::Number(n)) => n
82 .as_f64()
83 .map(|secs| iso_utc(secs)),
84 Some(Value::String(s)) => Some(s.clone()),
85 _ => None,
86 };
87 (pct, resets)
88}
89
90pub fn apply_claude_statusline(out: &mut AgentUsage, home: &Path, now: f64) {
93 let Some(snap) = load_statusline(home, now) else { return };
94 let empty = Value::Object(Default::default());
95 let ctx = snap.get("context_window").unwrap_or(&empty);
96 let current_usage = ctx.get("current_usage").unwrap_or(&empty);
97
98 let input_total = num_u64(ctx, "total_input_tokens").or_else(|| {
99 if current_usage.is_object() {
100 Some(
101 num_u64(current_usage, "input_tokens").unwrap_or(0)
102 + num_u64(current_usage, "cache_creation_input_tokens").unwrap_or(0)
103 + num_u64(current_usage, "cache_read_input_tokens").unwrap_or(0),
104 )
105 } else {
106 None
107 }
108 });
109 let output_total = num_u64(ctx, "total_output_tokens").or_else(|| {
110 if current_usage.is_object() {
111 Some(num_u64(current_usage, "output_tokens").unwrap_or(0))
112 } else {
113 None
114 }
115 });
116
117 let model = snap.get("model").unwrap_or(&empty);
118 let workspace = snap.get("workspace").unwrap_or(&empty);
119 let cwd = workspace
120 .get("current_dir")
121 .and_then(|v| v.as_str())
122 .or_else(|| snap.get("cwd").and_then(|v| v.as_str()));
123 let model_id = model
124 .get("id")
125 .and_then(|v| v.as_str())
126 .or_else(|| model.get("display_name").and_then(|v| v.as_str()));
127 let used_pct = parse_usage_percent(ctx.get("used_percentage"));
128 let window = num_u64(ctx, "context_window_size");
129
130 if let Some(u) = snap.get("updated_at").and_then(|v| v.as_str()) {
131 out.last_turn_at = Some(u.to_string());
132 }
133 if let Some(m) = model_id {
134 out.last_model = Some(m.to_string());
135 }
136 if let Some(c) = cwd {
137 out.last_cwd = Some(c.to_string());
138 }
139 if let Some(it) = input_total {
140 out.last_turn_input_tokens = it;
141 }
142 if let Some(ot) = output_total {
143 out.last_turn_output_tokens = ot;
144 }
145 if let Some(w) = window {
146 out.last_context_window = Some(w);
147 }
148 if let Some(p) = used_pct {
149 out.last_context_pct = Some(p);
150 }
151
152 let rate_limits = snap.get("rate_limits").unwrap_or(&empty);
153 for (keys, is_five) in [
154 (&["five_hour"][..], true),
155 (&["seven_day"][..], false),
156 (&["primary"][..], true),
157 (&["secondary"][..], false),
158 ] {
159 let (pct, resets) = parse_claude_rate_limit_window(rate_limits, keys);
160 if let Some(p) = pct {
161 if is_five {
162 out.session_5h_percent = Some(p);
163 } else {
164 out.week_7d_percent = Some(p);
165 }
166 }
167 if let Some(r) = resets {
168 if is_five {
169 out.session_5h_resets_at = Some(r);
170 } else {
171 out.week_7d_resets_at = Some(r);
172 }
173 }
174 }
175}
176
177fn now_ms(now: f64) -> i64 {
180 (now * 1000.0) as i64
181}
182
183fn token_from_oauth(data: &Value, now_ms: i64) -> Option<String> {
184 let oauth = data.get("claudeAiOauth")?;
185 let token = oauth.get("accessToken").and_then(|v| v.as_str())?;
186 if token.is_empty() {
187 return None;
188 }
189 match oauth.get("expiresAt") {
193 None | Some(Value::Null) => Some(token.to_string()),
194 Some(v) => match v.as_f64() {
195 Some(e) if e > now_ms as f64 => Some(token.to_string()),
196 _ => None,
197 },
198 }
199}
200
201fn read_claude_credentials(home: &Path, now: f64) -> Option<String> {
204 let now_ms = now_ms(now);
205 if let Ok(out) = std::process::Command::new("security")
207 .args(["find-generic-password", "-s", "Claude Code-credentials", "-w"])
208 .output()
209 {
210 if out.status.success() {
211 let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
212 if let Ok(data) = serde_json::from_str::<Value>(&raw) {
213 if let Some(t) = token_from_oauth(&data, now_ms) {
214 return Some(t);
215 }
216 } else if raw.starts_with("sk-ant") {
217 return Some(raw);
218 }
219 }
220 }
221 let path = home.join(".claude").join(".credentials.json");
222 let bytes = std::fs::read(&path).ok()?;
223 let data: Value = serde_json::from_slice(&bytes).ok()?;
224 token_from_oauth(&data, now_ms)
225}
226
227fn usage_cache_path(home: &Path) -> PathBuf {
228 home.join(".context-bar").join("usage_api_cache.json")
229}
230
231fn now_secs(now: f64) -> u64 {
232 now.max(0.0) as u64
233}
234
235fn fetch_claude_usage_api(home: &Path, now: f64) -> Option<Value> {
236 let cache = usage_cache_path(home);
237 let cached: Option<Value> = std::fs::read(&cache)
238 .ok()
239 .and_then(|b| serde_json::from_slice(&b).ok());
240 if let Some(c) = &cached {
241 let ts = c.get("timestamp").and_then(|v| v.as_u64()).unwrap_or(0);
242 let ttl = if c.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
243 CACHE_TTL_OK
244 } else {
245 CACHE_TTL_ERR
246 };
247 if ts > 0 && now_secs(now).saturating_sub(ts) < ttl {
248 return c.get("data").filter(|d| !d.is_null()).cloned();
249 }
250 }
251
252 let write_cache = |ok: bool, data: &Value| {
253 if let Some(parent) = cache.parent() {
254 let _ = std::fs::create_dir_all(parent);
255 }
256 let doc = serde_json::json!({"timestamp": now_secs(now), "ok": ok, "data": data});
257 if let Ok(bytes) = serde_json::to_vec(&doc) {
258 let _ = std::fs::write(&cache, bytes);
259 }
260 };
261
262 let Some(token) = read_claude_credentials(home, now) else {
263 write_cache(false, &Value::Null);
264 return None;
265 };
266
267 let fallback = cached
270 .as_ref()
271 .and_then(|c| c.get("data"))
272 .filter(|d| !d.is_null())
273 .cloned();
274
275 let resp = ureq::get("https://api.anthropic.com/api/oauth/usage")
276 .set("Authorization", &format!("Bearer {token}"))
277 .set("anthropic-beta", "oauth-2025-04-20")
278 .set("User-Agent", "claude-code/2.1")
279 .timeout(std::time::Duration::from_secs(15))
280 .call();
281 match resp {
282 Ok(r) => match r.into_json::<Value>() {
283 Ok(payload) => {
284 write_cache(true, &payload);
285 Some(payload)
286 }
287 Err(_) => {
289 write_cache(false, fallback.as_ref().unwrap_or(&Value::Null));
290 fallback
291 }
292 },
293 Err(ureq::Error::Status(_, _)) => {
295 write_cache(false, &Value::Null);
296 None
297 }
298 Err(_) => {
300 write_cache(false, fallback.as_ref().unwrap_or(&Value::Null));
301 fallback
302 }
303 }
304}
305
306pub fn apply_claude_usage_api(out: &mut AgentUsage, home: &Path, now: f64) {
309 let Some(payload) = fetch_claude_usage_api(home, now) else { return };
310 if !payload.is_object() {
311 return;
312 }
313 let empty = Value::Object(Default::default());
314 let five = payload.get("five_hour").unwrap_or(&empty);
315 let seven = payload.get("seven_day").unwrap_or(&empty);
316 out.session_5h_percent = parse_usage_percent(five.get("utilization"));
317 out.week_7d_percent = parse_usage_percent(seven.get("utilization"));
318 if let Some(r) = five.get("resets_at").and_then(|v| v.as_str()) {
319 out.session_5h_resets_at = Some(r.to_string());
320 }
321 if let Some(r) = seven.get("resets_at").and_then(|v| v.as_str()) {
322 out.week_7d_resets_at = Some(r.to_string());
323 }
324}
325
326fn epoch_to_iso(v: Option<&Value>, now: f64) -> Option<String> {
329 let secs = v?.as_f64()?;
330 if secs <= now {
331 return None;
332 }
333 Some(iso_utc(secs))
334}
335
336fn parse_codex_rate_limit_window(window: &Value, now: f64) -> (Option<f64>, Option<String>) {
337 if !window.is_object() {
338 return (None, None);
339 }
340 let pct = parse_usage_percent(window.get("usedPercent"))
341 .or_else(|| parse_usage_percent(window.get("used_percent")));
342 let resets = epoch_to_iso(window.get("resetsAt"), now)
343 .or_else(|| epoch_to_iso(window.get("resets_at"), now));
344 (pct, resets)
345}
346
347pub fn apply_codex_rate_limits(out: &mut AgentUsage, snapshot: &Value, now: f64) {
350 if !snapshot.is_object() {
351 return;
352 }
353 let empty = Value::Object(Default::default());
354 let (pct, resets) = parse_codex_rate_limit_window(snapshot.get("primary").unwrap_or(&empty), now);
355 if let Some(p) = pct {
356 out.session_5h_percent = Some(p);
357 }
358 if let Some(r) = resets {
359 out.session_5h_resets_at = Some(r);
360 }
361 let (pct, resets) =
362 parse_codex_rate_limit_window(snapshot.get("secondary").unwrap_or(&empty), now);
363 if let Some(p) = pct {
364 out.week_7d_percent = Some(p);
365 }
366 if let Some(r) = resets {
367 out.week_7d_resets_at = Some(r);
368 }
369}