claude_code_stats/source/
cli_probe.rs1use std::io::{Read, Write};
2use std::os::fd::{AsFd, AsRawFd, FromRawFd};
3use std::process::Command;
4use std::time::{Duration, Instant};
5
6use anyhow::{Result, anyhow};
7use regex::Regex;
8
9use crate::source::SourceError;
10use crate::source::UsageSource;
11use crate::types::{ApiResponse, ApiWindow};
12
13const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
14
15pub struct CliProbeSource;
16
17impl UsageSource for CliProbeSource {
18 fn name(&self) -> &'static str {
19 "cli_probe"
20 }
21
22 fn try_fetch(&self) -> Result<ApiResponse, SourceError> {
23 let which = Command::new("which")
24 .arg("claude")
25 .output()
26 .map_err(|e| SourceError::NotAvailable(format!("cannot check for claude: {e}")))?;
27
28 if !which.status.success() {
29 return Err(SourceError::NotAvailable(
30 "claude CLI not found".to_string(),
31 ));
32 }
33
34 run_cli_probe().map_err(SourceError::Failed)
35 }
36}
37
38fn run_cli_probe() -> Result<ApiResponse> {
39 let pty = nix::pty::openpty(None, None).map_err(|e| anyhow!("openpty failed: {e}"))?;
40
41 let slave_fd = pty.slave.as_raw_fd();
42 let master_fd = pty.master.as_raw_fd();
43
44 match unsafe { nix::unistd::fork() } {
45 Ok(nix::unistd::ForkResult::Child) => {
46 drop(pty.master);
47 let _ = nix::unistd::setsid();
48 let _ = nix::unistd::dup2(slave_fd, 0);
49 let _ = nix::unistd::dup2(slave_fd, 1);
50 let _ = nix::unistd::dup2(slave_fd, 2);
51 drop(pty.slave);
52
53 let err = nix::unistd::execvp(c"claude", &[c"claude", c"--allowed-tools", c""]);
54 eprintln!("execvp failed: {err:?}");
55 std::process::exit(1);
56 }
57 Ok(nix::unistd::ForkResult::Parent { child }) => {
58 drop(pty.slave);
59
60 let result = interact_with_pty(master_fd);
61
62 let _ = nix::sys::signal::kill(child, nix::sys::signal::Signal::SIGTERM);
63 let _ = nix::sys::wait::waitpid(child, None);
64
65 result
66 }
67 Err(e) => Err(anyhow!("fork failed: {e}")),
68 }
69}
70
71fn interact_with_pty(master_fd: i32) -> Result<ApiResponse> {
72 let mut master = unsafe { std::fs::File::from_raw_fd(master_fd) };
73 let start = Instant::now();
74 let mut buffer = Vec::new();
75 let mut read_buf = [0u8; 4096];
76 let mut sent_usage = false;
77 let mut usage_output_start = 0;
78
79 let borrowed = master.as_fd();
81 let flags = nix::fcntl::fcntl(borrowed.as_raw_fd(), nix::fcntl::FcntlArg::F_GETFL)?;
82 let mut oflags = nix::fcntl::OFlag::from_bits_truncate(flags);
83 oflags.insert(nix::fcntl::OFlag::O_NONBLOCK);
84 nix::fcntl::fcntl(borrowed.as_raw_fd(), nix::fcntl::FcntlArg::F_SETFL(oflags))?;
85
86 loop {
87 if start.elapsed() > PROBE_TIMEOUT {
88 return Err(anyhow!("CLI probe timed out after {PROBE_TIMEOUT:?}"));
89 }
90
91 match master.read(&mut read_buf) {
92 Ok(0) => break,
93 Ok(n) => {
94 buffer.extend_from_slice(&read_buf[..n]);
95 }
96 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
97 std::thread::sleep(Duration::from_millis(100));
98 }
99 Err(e) => return Err(anyhow!("read error: {e}")),
100 }
101
102 let text = String::from_utf8_lossy(&buffer);
103
104 if text.contains("Do you trust") {
105 let _ = master.write_all(b"y\n");
106 let _ = master.flush();
107 buffer.clear();
108 continue;
109 }
110
111 if text.contains("Quick safety check") || text.contains("press Enter") {
112 let _ = master.write_all(b"\n");
113 let _ = master.flush();
114 buffer.clear();
115 continue;
116 }
117
118 if !sent_usage
119 && (text.contains("> ") || text.contains("\u{276F}") || text.contains("claude>"))
120 {
121 let _ = master.write_all(b"/usage\n");
122 let _ = master.flush();
123 sent_usage = true;
124 usage_output_start = buffer.len();
125 continue;
126 }
127
128 if sent_usage {
129 let output_text = String::from_utf8_lossy(&buffer[usage_output_start..]);
130 if output_text.contains("> ")
131 || output_text.contains("\u{276F}")
132 || output_text.contains("claude>")
133 {
134 let full_output = String::from_utf8_lossy(&buffer);
135 let cleaned = strip_ansi_codes(&full_output);
136 return parse_usage_output(&cleaned);
137 }
138 }
139 }
140
141 let full_output = String::from_utf8_lossy(&buffer);
142 let cleaned = strip_ansi_codes(&full_output);
143 parse_usage_output(&cleaned)
144}
145
146fn strip_ansi_codes(s: &str) -> String {
147 let re = Regex::new(r"\x1B\[[0-?]*[ -/]*[@-~]").expect("invalid regex");
148 re.replace_all(s, "").to_string()
149}
150
151fn parse_usage_output(output: &str) -> Result<ApiResponse> {
152 let pct_re = Regex::new(r"(\d{1,3}(?:\.\d+)?)\s*%").expect("invalid regex");
153 let resets_re = Regex::new(r"(?i)resets?\s+(.*?)(?:\n|$)").expect("invalid regex");
154
155 let mut five_hour: Option<ApiWindow> = None;
156 let mut seven_day: Option<ApiWindow> = None;
157 let mut seven_day_opus: Option<ApiWindow> = None;
158
159 let sections = [
160 ("Current session", &mut five_hour),
161 ("Current week (all models)", &mut seven_day),
162 ("Current week (Opus", &mut seven_day_opus),
163 ];
164
165 for (label, target) in sections {
166 if let Some(pos) = output.find(label) {
167 let section = &output[pos..];
168 let section_end = section[label.len()..]
169 .find("Current")
170 .map(|p| p + label.len())
171 .unwrap_or(section.len());
172 let section_text = §ion[..section_end];
173
174 if let Some(caps) = pct_re.captures(section_text) {
175 let raw_pct: f64 = caps[1].parse().unwrap_or(0.0);
176
177 let lower = section_text.to_lowercase();
178 let utilization = if lower.contains("left") || lower.contains("remaining") {
179 100.0 - raw_pct
180 } else {
181 raw_pct
182 };
183
184 let resets_at = resets_re
185 .captures(section_text)
186 .map(|c| c[1].trim().to_string());
187
188 *target = Some(ApiWindow {
189 utilization: Some(utilization),
190 resets_at,
191 });
192 }
193 }
194 }
195
196 if five_hour.is_none() && seven_day.is_none() && seven_day_opus.is_none() {
197 return Err(anyhow!("could not parse usage from CLI output:\n{output}"));
198 }
199
200 Ok(ApiResponse {
201 five_hour,
202 seven_day,
203 seven_day_sonnet: None,
204 seven_day_opus,
205 extra_usage: None,
206 })
207}