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