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