Skip to main content

seher/codexbar/
client.rs

1//! Spawns the external `codexbar` binary and parses its JSON usage payload.
2//!
3//! Mirrors `seher-ts/packages/sdk/src/codexbar/client.ts`. We follow the
4//! codebase convention of running blocking `std::process` work on a Tokio
5//! blocking thread (see `kiro::client`) rather than pulling in the tokio
6//! `process` feature. A hard timeout is enforced by polling `try_wait` and
7//! killing the child once the deadline passes.
8
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13use super::errors::CodexBarError;
14use super::types::CodexBarUsageResponse;
15
16const DEFAULT_BIN: &str = "/usr/local/bin/codexbar";
17const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
18const POLL_INTERVAL: Duration = Duration::from_millis(50);
19/// codexbar exits with this code when its own internal usage fetch times out.
20const TIMEOUT_EXIT_CODE: i32 = 4;
21
22/// Options for [`run_codexbar_usage`]. Defaults mirror seher-ts.
23#[derive(Debug, Clone, Default)]
24pub struct RunCodexBarUsageOptions {
25    /// Explicit binary path; overrides `SEHER_CODEXBAR_BIN`, PATH lookup and the default.
26    pub bin_path: Option<String>,
27    /// `--account <label>` selector.
28    pub account_label: Option<String>,
29    /// `--account-index <n>` selector.
30    pub account_index: Option<i64>,
31    /// Hard timeout; defaults to 15s.
32    pub timeout: Option<Duration>,
33}
34
35fn which_codexbar() -> Option<PathBuf> {
36    let path = std::env::var_os("PATH")?;
37    for dir in std::env::split_paths(&path) {
38        if dir.as_os_str().is_empty() {
39            continue;
40        }
41        let candidate = dir.join("codexbar");
42        if candidate.is_file() {
43            return Some(candidate);
44        }
45    }
46    None
47}
48
49fn resolve_bin_path(explicit: Option<&str>) -> PathBuf {
50    if let Some(p) = explicit
51        && !p.is_empty()
52    {
53        return PathBuf::from(p);
54    }
55    if let Ok(env_bin) = std::env::var("SEHER_CODEXBAR_BIN")
56        && !env_bin.is_empty()
57    {
58        return PathBuf::from(env_bin);
59    }
60    which_codexbar().unwrap_or_else(|| PathBuf::from(DEFAULT_BIN))
61}
62
63/// Run `codexbar usage --format json --provider <provider>` and return the entry
64/// matching `provider`.
65///
66/// # Errors
67///
68/// Returns [`CodexBarError`] when the binary is missing, the process fails or
69/// times out, the output is not valid JSON, or no entry matches `provider`.
70pub async fn run_codexbar_usage(
71    provider: &str,
72    opts: &RunCodexBarUsageOptions,
73) -> Result<CodexBarUsageResponse, CodexBarError> {
74    let bin = resolve_bin_path(opts.bin_path.as_deref());
75    let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
76    let provider = provider.to_string();
77
78    let mut args: Vec<String> = vec![
79        "usage".into(),
80        "--format".into(),
81        "json".into(),
82        "--provider".into(),
83        provider.clone(),
84    ];
85    if let Some(label) = &opts.account_label {
86        args.push("--account".into());
87        args.push(label.clone());
88    }
89    if let Some(idx) = opts.account_index {
90        args.push("--account-index".into());
91        args.push(idx.to_string());
92    }
93
94    let provider_for_blocking = provider.clone();
95    let raw = tokio::task::spawn_blocking(move || {
96        run_blocking(&bin, &args, timeout, &provider_for_blocking)
97    })
98    .await
99    .map_err(|e| CodexBarError::Spawn(e.to_string()))??;
100
101    parse_response(&raw.stdout, &raw.stderr, raw.code, &provider)
102}
103
104struct RawOutput {
105    stdout: String,
106    stderr: String,
107    code: Option<i32>,
108}
109
110/// Drain a child pipe to a `String` on its own thread. Returning the reader as a
111/// join handle lets the caller consume stdout/stderr concurrently with the
112/// process-completion poll loop.
113fn spawn_reader<R: std::io::Read + Send + 'static>(
114    pipe: Option<R>,
115) -> std::thread::JoinHandle<String> {
116    std::thread::spawn(move || {
117        let mut buf = String::new();
118        if let Some(mut p) = pipe {
119            let _ = p.read_to_string(&mut buf);
120        }
121        buf
122    })
123}
124
125fn run_blocking(
126    bin: &Path,
127    args: &[String],
128    timeout: Duration,
129    provider: &str,
130) -> Result<RawOutput, CodexBarError> {
131    let mut child = match Command::new(bin)
132        .args(args)
133        .stdin(Stdio::null())
134        .stdout(Stdio::piped())
135        .stderr(Stdio::piped())
136        .spawn()
137    {
138        Ok(c) => c,
139        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
140            return Err(CodexBarError::NotFound {
141                bin: bin.display().to_string(),
142            });
143        }
144        Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
145    };
146
147    // Drain stdout/stderr on dedicated threads so a child that fills the OS pipe
148    // buffer (~64KB) can't deadlock: it would block on write while our `try_wait`
149    // loop waits for an exit that never comes. Mirrors seher-ts, which reads the
150    // streams concurrently with process completion.
151    let stdout_reader = spawn_reader(child.stdout.take());
152    let stderr_reader = spawn_reader(child.stderr.take());
153
154    let timeout_ms = timeout.as_millis();
155    let deadline = Instant::now() + timeout;
156    let status = loop {
157        match child.try_wait() {
158            Ok(Some(status)) => break status,
159            Ok(None) => {
160                if Instant::now() >= deadline {
161                    let _ = child.kill();
162                    let _ = child.wait();
163                    // Readers unblock at EOF once the killed child's pipes close.
164                    let _ = stdout_reader.join();
165                    let _ = stderr_reader.join();
166                    return Err(CodexBarError::Timeout {
167                        provider: provider.to_string(),
168                        ms: timeout_ms,
169                    });
170                }
171                std::thread::sleep(POLL_INTERVAL);
172            }
173            Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
174        }
175    };
176
177    let stdout = stdout_reader.join().unwrap_or_default();
178    let stderr = stderr_reader.join().unwrap_or_default();
179
180    // codexbar exits 4 when its own usage fetch times out internally.
181    if status.code() == Some(TIMEOUT_EXIT_CODE) {
182        return Err(CodexBarError::Timeout {
183            provider: provider.to_string(),
184            ms: timeout_ms,
185        });
186    }
187
188    Ok(RawOutput {
189        stdout,
190        stderr,
191        code: status.code(),
192    })
193}
194
195fn parse_response(
196    stdout: &str,
197    stderr: &str,
198    code: Option<i32>,
199    provider: &str,
200) -> Result<CodexBarUsageResponse, CodexBarError> {
201    if code != Some(0) {
202        return Err(CodexBarError::Exited {
203            code,
204            provider: provider.to_string(),
205            stderr: stderr.trim().to_string(),
206        });
207    }
208
209    let value: serde_json::Value =
210        serde_json::from_str(stdout).map_err(|e| CodexBarError::Parse(e.to_string()))?;
211    // codexbar emits a JSON array (one entry per provider) even when --provider
212    // selects a single one — unwrap to the matching entry.
213    let entries = value
214        .as_array()
215        .ok_or_else(|| CodexBarError::NonArray(provider.to_string()))?;
216    for item in entries {
217        let Ok(entry) = serde_json::from_value::<CodexBarUsageResponse>(item.clone()) else {
218            continue;
219        };
220        if entry.provider == provider {
221            return Ok(entry);
222        }
223    }
224    Err(CodexBarError::NoEntry(provider.to_string()))
225}
226
227#[cfg(test)]
228#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn resolve_bin_path_prefers_explicit() {
234        let p = resolve_bin_path(Some("/custom/codexbar"));
235        assert_eq!(p, PathBuf::from("/custom/codexbar"));
236    }
237
238    #[test]
239    fn parse_response_unwraps_matching_provider() {
240        let stdout = r#"[{"provider":"claude","usage":{"primary":{"usedPercent":40}}}]"#;
241        let entry = parse_response(stdout, "", Some(0), "claude").expect("entry");
242        assert_eq!(entry.provider, "claude");
243        let primary = entry.usage.primary.expect("primary");
244        assert!((primary.used_percent - 40.0).abs() < f64::EPSILON);
245    }
246
247    #[test]
248    fn parse_response_no_entry_for_unknown_provider() {
249        let stdout = r#"[{"provider":"claude","usage":{}}]"#;
250        let err = parse_response(stdout, "", Some(0), "zai").expect_err("no entry");
251        assert!(matches!(err, CodexBarError::NoEntry(_)));
252    }
253
254    #[test]
255    fn parse_response_nonzero_exit_is_error() {
256        let err = parse_response("", "boom", Some(2), "claude").expect_err("exit err");
257        assert!(matches!(err, CodexBarError::Exited { .. }));
258    }
259
260    #[test]
261    fn parse_response_non_array_payload() {
262        let err = parse_response("{}", "", Some(0), "claude").expect_err("non-array");
263        assert!(matches!(err, CodexBarError::NonArray(_)));
264    }
265}