Skip to main content

context_bar_core/
online.rs

1//! Online / host enrichments — the final slice-4 piece of the Python→Rust port.
2//! Ports the statusline-snapshot overlay, the Anthropic usage API (account 5h/7d
3//! %), Codex transcript rate-limits, and cross-platform credential discovery
4//! (`~/.claude/.credentials.json`; macOS keychain). All best-effort: each
5//! degrades to a no-op offline / without credentials, exactly like the Python.
6//! Native-only (HTTP + subprocess + filesystem).
7
8use 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
23/// `parse_usage_percent`: a number clamped to [0,200], rounded to 1 dp; else None.
24pub 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
33// ---- Claude statusline snapshot -------------------------------------------
34
35fn 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
64/// Navigate `rate_limits` by `keys`, returning (percent, resets_at) — mirrors
65/// `parse_claude_rate_limit_window`.
66fn 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
90/// Overlay the statusline snapshot (authoritative for live context fields when
91/// fresh). Mirrors `apply_claude_statusline_snapshot`.
92pub 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
177// ---- Anthropic usage API (account limits %) -------------------------------
178
179fn 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    // Python: `token and (expiresAt is None or expiresAt > now_ms)`. A present
190    // non-numeric expiresAt raises in Python (→ rejected), so: missing/null →
191    // accept; numeric → must be in the future; anything else → reject.
192    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
201/// Read the Claude OAuth token: macOS keychain first, then
202/// `~/.claude/.credentials.json` (cross-platform). Mirrors `read_claude_credentials`.
203fn read_claude_credentials(home: &Path, now: f64) -> Option<String> {
204    let now_ms = now_ms(now);
205    // macOS keychain (no-op / error elsewhere).
206    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    // Prior good payload, kept as a fallback on transport/body errors (mirrors
268    // the Python `except` branch).
269    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            // 2xx with an unparseable body — Python's `except` keeps prior data.
288            Err(_) => {
289                write_cache(false, fallback.as_ref().unwrap_or(&Value::Null));
290                fallback
291            }
292        },
293        // Non-2xx response — Python's `status != 200` returns None + null cache.
294        Err(ureq::Error::Status(_, _)) => {
295            write_cache(false, &Value::Null);
296            None
297        }
298        // Transport/network error — Python's `except` keeps prior data.
299        Err(_) => {
300            write_cache(false, fallback.as_ref().unwrap_or(&Value::Null));
301            fallback
302        }
303    }
304}
305
306/// Overlay account 5h/7d utilization from the Anthropic usage API. Mirrors
307/// `apply_claude_usage_api`.
308pub 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
326// ---- Codex rate limits (from transcript) ----------------------------------
327
328fn 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
347/// Apply Codex rate-limit windows (primary→5h, secondary→7d). Mirrors
348/// `apply_codex_rate_limits`.
349pub 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}